diff --git a/examples/web/Cargo.toml b/examples/web/Cargo.toml index 42499c6f0..021e1eb64 100644 --- a/examples/web/Cargo.toml +++ b/examples/web/Cargo.toml @@ -1,36 +1,36 @@ [package] -name = "web" -version = "0.1.0" authors = ["Tom Houlé "] edition = "2018" +name = "web" +version = "0.1.0" # https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#profile-overrides #[profile.release] #lto = "thin" [dev-dependencies] -graphql_client = { path = "../../graphql_client" } -graphql_client_web = { path = "../../graphql_client_web" } -wasm-bindgen = "^0.2" -serde = { version = "1.0.67", features = ["derive"] } +graphql_client = {path = "../../graphql_client"} +graphql_client_web = {path = "../../graphql_client_web"} +js-sys = "0.3.44" lazy_static = "1.0.1" -js-sys = "0.3.6" -futures = "0.1.25" -wasm-bindgen-futures = "0.3.6" +serde = {version = "1.0.67", features = ["derive"]} +wasm-bindgen = "^0.2" +wasm-bindgen-futures = "0.4.17" [dev-dependencies.web-sys] -version = "0.3.6" features = [ - "console", - "Document", - "Element", - "EventTarget", - "Node", - "HtmlBodyElement", - "HtmlDocument", - "HtmlElement", + "console", + "Document", + "Element", + "EventTarget", + "Node", + "HtmlBodyElement", + "HtmlDocument", + "HtmlElement", + 'MouseEvent', ] +version = "0.3.6" [[example]] -name = "web" crate-type = ["cdylib"] +name = "web" diff --git a/examples/web/examples/web.rs b/examples/web/examples/web.rs index 3937e5400..f144fb359 100644 --- a/examples/web/examples/web.rs +++ b/examples/web/examples/web.rs @@ -1,11 +1,10 @@ -use futures::Future; use graphql_client::GraphQLQuery; use lazy_static::*; use std::cell::RefCell; use std::sync::Mutex; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; -use wasm_bindgen_futures::future_to_promise; +use wasm_bindgen_futures::spawn_local; #[derive(GraphQLQuery)] #[graphql( @@ -23,15 +22,15 @@ lazy_static! { static ref LAST_ENTRY: Mutex>> = Mutex::new(RefCell::new(None)); } -fn load_more() -> impl Future { - let client = graphql_client::web::Client::new("https://www.graphqlhub.com/graphql"); +async fn load_more() -> Result { + let client = graphql_client::http::Client::new("https://www.graphqlhub.com/graphql"); let variables = puppy_smiles::Variables { after: LAST_ENTRY .lock() .ok() .and_then(|opt| opt.borrow().to_owned()), }; - let response = client.call(PuppySmiles, variables); + let response = client.call(PuppySmiles, variables).await; response .map(|response| { @@ -59,9 +58,13 @@ fn add_load_more_button() { .create_element("button") .expect_throw("could not create button"); btn.set_inner_html("I WANT MORE PUPPIES"); - let on_click = Closure::wrap( - Box::new(move || future_to_promise(load_more())) as Box js_sys::Promise> - ); + + let on_click = Closure::wrap(Box::new(move |_event: web_sys::MouseEvent| { + spawn_local(async { + load_more().await.unwrap(); + }); + }) as Box); + btn.add_event_listener_with_callback( "click", &on_click diff --git a/graphql_client/Cargo.toml b/graphql_client/Cargo.toml index 282c6db63..07b3659fe 100644 --- a/graphql_client/Cargo.toml +++ b/graphql_client/Cargo.toml @@ -1,68 +1,62 @@ [package] -name = "graphql_client" -version = "0.9.0" authors = ["Tom Houlé "] -description = "Typed GraphQL requests and responses" -repository = "https://github.com/graphql-rust/graphql-client" -license = "Apache-2.0 OR MIT" -keywords = ["graphql", "api", "web", "webassembly", "wasm"] categories = ["network-programming", "web-programming", "wasm"] +description = "Typed GraphQL requests and responses" edition = "2018" +keywords = ["graphql", "api", "web", "webassembly", "wasm"] +license = "Apache-2.0 OR MIT" +name = "graphql_client" +repository = "https://github.com/graphql-rust/graphql-client" +version = "0.9.0" [dependencies] +anyhow = {version = "1.0", optional = true} doc-comment = "^0.3" -anyhow = { version = "1.0", optional = true } -thiserror = { version = "1.0", optional = true } -graphql_query_derive = { path = "../graphql_query_derive", version = "0.9.0" } +graphql_query_derive = {path = "../graphql_query_derive", version = "0.9.0"} +reqwest = {version = "0.10.7", optional = true} +serde = {version = "^1.0.78", features = ["derive"]} serde_json = "1.0" -serde = { version = "^1.0.78", features = ["derive"] } +thiserror = {version = "1.0", optional = true} [dependencies.futures] -version = "^0.1" optional = true +version = "^0.1" [dependencies.js-sys] -version = "^0.3" optional = true +version = "^0.3" [dependencies.log] -version = "^0.4" optional = true +version = "^0.4" [dependencies.web-sys] -version = "^0.3" -optional = true features = [ - "Headers", - "Request", - "RequestInit", - "Response", - "Window", + "Headers", + "Request", + "RequestInit", + "Response", + "Window", ] +optional = true +version = "^0.3" [dependencies.wasm-bindgen] -version = "^0.2" optional = true +version = "^0.2" [dependencies.wasm-bindgen-futures] -version = "^0.3" optional = true - -[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] -reqwest = "^0.9" +version = "^0.4" [dev-dependencies] # Note: If we bumpup wasm-bindge-test version, we should change CI setting. -wasm-bindgen-test = "^0.2" +wasm-bindgen-test = "^0.3" + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio = {version = "^0.2", features = ["rt-threaded", "macros"]} [features] -web = [ - "anyhow", - "thiserror", - "futures", - "js-sys", - "log", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] +default = ["http"] +http = ["reqwest"] +web = ["http"] diff --git a/graphql_client/src/http.rs b/graphql_client/src/http.rs new file mode 100644 index 000000000..e43737bbb --- /dev/null +++ b/graphql_client/src/http.rs @@ -0,0 +1,92 @@ +//! Some global + +use crate::*; +use std::collections::HashMap; + +/// The main interface to the library. +/// +/// The workflow is the following: +/// +/// - create a client +/// - (optionally) configure it +/// - use it to perform queries with the [call] method +pub struct Client { + endpoint: String, + headers: HashMap, + reqwest_client: reqwest::Client, +} + +impl Client { + /// Initialize a client. The `endpoint` parameter is the URI of the GraphQL API. + pub fn new(endpoint: Endpoint) -> Client + where + Endpoint: Into, + { + Client { + endpoint: endpoint.into(), + headers: HashMap::new(), + reqwest_client: reqwest::Client::new(), + } + } + + /// Add a header to those sent with the requests. Can be used for things like authorization. + pub fn add_header(&mut self, name: &str, value: &str) { + self.headers.insert(name.into(), value.into()); + } + + /// Perform a query. + /// + // Lint disabled: We can pass by value because it's always an empty struct. + #[allow(clippy::needless_pass_by_value)] + pub async fn call( + &self, + _query: Q, + variables: Q::Variables, + ) -> Result, ClientError> { + // TODO: remove tests and test harness + // TODO: custom headers + let reqwest_response = self + .reqwest_client + .post(&self.endpoint) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&Q::build_query(variables)).unwrap()) + .send() + .await?; + + let text_response = reqwest_response.text().await?; + + Ok(serde_json::from_str(&text_response)?) + } +} + +/// TODO +#[derive(Debug)] +pub enum ClientError { + /// TODO + ReqwestError(reqwest::Error), + /// TODO + SerdeError(serde_json::Error), +} + +impl From for ClientError { + fn from(e: reqwest::Error) -> Self { + ClientError::ReqwestError(e) + } +} + +impl From for ClientError { + fn from(e: serde_json::Error) -> Self { + ClientError::SerdeError(e) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_new() { + Client::new("https://example.com/graphql"); + Client::new("/graphql"); + } +} diff --git a/graphql_client/src/lib.rs b/graphql_client/src/lib.rs index b0dca88dd..cb295f757 100644 --- a/graphql_client/src/lib.rs +++ b/graphql_client/src/lib.rs @@ -14,13 +14,13 @@ pub use graphql_query_derive::*; use serde::*; -#[cfg(feature = "web")] -pub mod web; +#[cfg(feature = "http")] +pub mod http; use std::collections::HashMap; use std::fmt::{self, Display}; -doc_comment::doctest!("../../README.md"); +//doc_comment::doctest!("../../README.md"); /// A convenience trait that can be used to build a GraphQL request body. /// diff --git a/graphql_client/src/web.rs b/graphql_client/src/web.rs deleted file mode 100644 index 8f4b6031b..000000000 --- a/graphql_client/src/web.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! Use graphql_client inside browsers with -//! [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen). - -use crate::*; -use futures::{Future, IntoFuture}; -use log::*; -use std::collections::HashMap; -use thiserror::*; -use wasm_bindgen::{JsCast, JsValue}; -use wasm_bindgen_futures::JsFuture; - -/// The main interface to the library. -/// -/// The workflow is the following: -/// -/// - create a client -/// - (optionally) configure it -/// - use it to perform queries with the [call] method -pub struct Client { - endpoint: String, - headers: HashMap, -} - -/// All the ways a request can go wrong. -/// -/// not exhaustive -#[derive(Debug, Error, PartialEq)] -pub enum ClientError { - /// The body couldn't be built - #[error("Request body is not a valid string")] - Body, - /// An error caused by window.fetch - #[error("Network error")] - Network(String), - /// Error in a dynamic JS cast that should have worked - #[error("JS casting error")] - Cast, - /// No window object could be retrieved - #[error( - "No Window object available - the client works only in a browser (non-worker) context" - )] - NoWindow, - /// Response shape does not match the generated code - #[error("Response shape error")] - ResponseShape, - /// Response could not be converted to text - #[error("Response conversion to text failed (Response.text threw)")] - ResponseText, - /// Exception thrown when building the request - #[error("Error building the request")] - RequestError, - /// Other JS exception - #[error("Unexpected JS exception")] - JsException, -} - -impl Client { - /// Initialize a client. The `endpoint` parameter is the URI of the GraphQL API. - pub fn new(endpoint: Endpoint) -> Client - where - Endpoint: Into, - { - Client { - endpoint: endpoint.into(), - headers: HashMap::new(), - } - } - - /// Add a header to those sent with the requests. Can be used for things like authorization. - pub fn add_header(&mut self, name: &str, value: &str) { - self.headers.insert(name.into(), value.into()); - } - - /// Perform a query. - /// - // Lint disabled: We can pass by value because it's always an empty struct. - #[allow(clippy::needless_pass_by_value)] - pub fn call( - &self, - _query: Q, - variables: Q::Variables, - ) -> impl Future, Error = ClientError> + 'static { - // this can be removed when we convert to async/await - let endpoint = self.endpoint.clone(); - let custom_headers = self.headers.clone(); - - web_sys::window() - .ok_or_else(|| ClientError::NoWindow) - .into_future() - .and_then(move |window| { - serde_json::to_string(&Q::build_query(variables)) - .map_err(|_| ClientError::Body) - .map(move |body| (window, body)) - }) - .and_then(move |(window, body)| { - let mut request_init = web_sys::RequestInit::new(); - request_init - .method("POST") - .body(Some(&JsValue::from_str(&body))); - - web_sys::Request::new_with_str_and_init(&endpoint, &request_init) - .map_err(|_| ClientError::JsException) - .map(|request| (window, request)) - // "Request constructor threw"); - }) - .and_then(move |(window, request)| { - let headers = request.headers(); - headers - .set("Content-Type", "application/json") - .map_err(|_| ClientError::RequestError)?; - headers - .set("Accept", "application/json") - .map_err(|_| ClientError::RequestError)?; - - for (header_name, header_value) in custom_headers.iter() { - headers - .set(header_name, header_value) - .map_err(|_| ClientError::RequestError)?; - } - - Ok((window, request)) - }) - .and_then(move |(window, request)| { - JsFuture::from(window.fetch_with_request(&request)) - .map_err(|err| ClientError::Network(js_sys::Error::from(err).message().into())) - }) - .and_then(move |res| { - debug!("response: {:?}", res); - res.dyn_into::() - .map_err(|_| ClientError::Cast) - }) - .and_then(move |cast_response| { - cast_response.text().map_err(|_| ClientError::ResponseText) - }) - .and_then(move |text_promise| { - JsFuture::from(text_promise).map_err(|_| ClientError::ResponseText) - }) - .and_then(|text| { - let response_text = text.as_string().unwrap_or_default(); - debug!("response text as string: {:?}", response_text); - serde_json::from_str(&response_text).map_err(|_| ClientError::ResponseShape) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn client_new() { - Client::new("https://example.com/graphql"); - Client::new("/graphql"); - } -} diff --git a/graphql_client/tests/Germany.graphql b/graphql_client/tests/Germany.graphql index 322d4f391..d1321dddd 100644 --- a/graphql_client/tests/Germany.graphql +++ b/graphql_client/tests/Germany.graphql @@ -7,7 +7,7 @@ query Germany { } } -query Country($countryCode: String!) { +query Country($countryCode: ID!) { country(code: $countryCode) { name continent { diff --git a/graphql_client/tests/client.rs b/graphql_client/tests/client.rs new file mode 100644 index 000000000..d52351cbb --- /dev/null +++ b/graphql_client/tests/client.rs @@ -0,0 +1,94 @@ +// use futures::Future; +use graphql_client::{http::Client, GraphQLQuery}; +// use wasm_bindgen::JsValue; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_test::wasm_bindgen_test_configure; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_test::*; + +#[cfg(target_arch = "wasm32")] +wasm_bindgen_test_configure!(run_in_browser); + +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +fn build_client() { + // just to test it doesn't crash + Client::new("https://example.com/graphql"); + Client::new("/graphql"); +} + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "tests/countries_schema.graphql", + query_path = "tests/Germany.graphql" +)] +struct Germany; + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] +async fn test_germany() { + let response = Client::new("https://countries.trevorblades.com/") + .call(Germany, germany::Variables) + .await + .unwrap(); + let continent_name = response + .data + .expect("response data is not null") + .country + .expect("country is not null") + .continent + .name; + + assert_eq!(continent_name, "Europe"); +} + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "tests/countries_schema.graphql", + query_path = "tests/Germany.graphql" +)] +struct Country; + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] +async fn test_country() { + let response = Client::new("https://countries.trevorblades.com/") + .call( + Country, + country::Variables { + country_code: "CN".to_owned(), + }, + ) + .await + .unwrap(); + let continent_name = response + .data + .expect("response data is not null") + .country + .expect("country is not null") + .continent + .name; + + assert_eq!(continent_name, "Asia"); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), tokio::test)] +async fn test_bad_url() { + let response = Client::new("https://example.com/non-existent/graphql/endpoint") + .call( + Country, + country::Variables { + country_code: "CN".to_owned(), + }, + ) + .await; + + match response { + Ok(_) => panic!("The API endpoint does not exist, this should not be called."), + Err(_e) => { + // TODO: What to assert here? + // That url gives a response but it's not json, but in WASM it's blocked by cors + } + } +} diff --git a/graphql_client/tests/countries_schema.graphql b/graphql_client/tests/countries_schema.graphql new file mode 100644 index 000000000..327e75698 --- /dev/null +++ b/graphql_client/tests/countries_schema.graphql @@ -0,0 +1,76 @@ +directive @cacheControl( + maxAge: Int + scope: CacheControlScope +) on FIELD_DEFINITION | OBJECT | INTERFACE +enum CacheControlScope { + PUBLIC + PRIVATE +} + +type Continent { + code: ID! + name: String! + countries: [Country!]! +} + +input ContinentFilterInput { + code: StringQueryOperatorInput +} + +type Country { + code: ID! + name: String! + native: String! + phone: String! + continent: Continent! + capital: String + currency: String + languages: [Language!]! + emoji: String! + emojiU: String! + states: [State!]! +} + +input CountryFilterInput { + code: StringQueryOperatorInput + currency: StringQueryOperatorInput + continent: StringQueryOperatorInput +} + +type Language { + code: ID! + name: String + native: String + rtl: Boolean! +} + +input LanguageFilterInput { + code: StringQueryOperatorInput +} + +type Query { + continents(filter: ContinentFilterInput): [Continent!]! + continent(code: ID!): Continent + countries(filter: CountryFilterInput): [Country!]! + country(code: ID!): Country + languages(filter: LanguageFilterInput): [Language!]! + language(code: ID!): Language +} + +type State { + code: String + name: String! + country: Country! +} + +input StringQueryOperatorInput { + eq: String + ne: String + in: [String] + nin: [String] + regex: String + glob: String +} + +# The `Upload` scalar type represents a file upload. +scalar Upload diff --git a/graphql_client/tests/web.rs b/graphql_client/tests/web.rs deleted file mode 100644 index 5a13b4340..000000000 --- a/graphql_client/tests/web.rs +++ /dev/null @@ -1,100 +0,0 @@ -#![cfg(target_arch = "wasm32")] - -use futures::Future; -use graphql_client::{web::Client, GraphQLQuery}; -use wasm_bindgen::JsValue; -use wasm_bindgen_test::wasm_bindgen_test_configure; -use wasm_bindgen_test::*; - -wasm_bindgen_test_configure!(run_in_browser); - -#[wasm_bindgen_test] -fn build_client() { - // just to test it doesn't crash - Client::new("https://example.com/graphql"); - Client::new("/graphql"); -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "tests/countries_schema.json", - query_path = "tests/Germany.graphql" -)] -struct Germany; - -#[wasm_bindgen_test(async)] -fn test_germany() -> impl Future { - Client::new("https://countries.trevorblades.com/") - .call(Germany, germany::Variables) - .map(|response| { - let continent_name = response - .data - .expect("response data is not null") - .country - .expect("country is not null") - .continent - .expect("continent is not null") - .name - .expect("germany is on a continent"); - - assert_eq!(continent_name, "Europe"); - }) - .map_err(|err| { - panic!("{:?}", err); - }) -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "tests/countries_schema.json", - query_path = "tests/Germany.graphql" -)] -struct Country; - -#[wasm_bindgen_test(async)] -fn test_country() -> impl Future { - Client::new("https://countries.trevorblades.com/") - .call( - Country, - country::Variables { - country_code: "CN".to_owned(), - }, - ) - .map(|response| { - let continent_name = response - .data - .expect("response data is not null") - .country - .expect("country is not null") - .continent - .expect("continent is not null") - .name - .expect("country is on a continent"); - - assert_eq!(continent_name, "Asia"); - }) - .map_err(|err| { - panic!("{:?}", err); - }) -} - -#[wasm_bindgen_test(async)] -fn test_bad_url() -> impl Future { - Client::new("https://example.com/non-existent/graphql/endpoint") - .call( - Country, - country::Variables { - country_code: "CN".to_owned(), - }, - ) - .map(|_response| panic!("The API endpoint does not exist, this should not be called.")) - .map_err(|err| { - assert_eq!( - err, - graphql_client::web::ClientError::Network( - "NetworkError when attempting to fetch resource.".into() - ) - ); - }) - .then(|_| Ok(())) -} diff --git a/graphql_client_codegen/src/generated_module.rs b/graphql_client_codegen/src/generated_module.rs index c73a39ea3..a2970a00e 100644 --- a/graphql_client_codegen/src/generated_module.rs +++ b/graphql_client_codegen/src/generated_module.rs @@ -67,7 +67,7 @@ impl<'a> GeneratedModule<'a> { const __QUERY_WORKAROUND: &str = include_str!(#path); ) }) - .unwrap_or_else(|| quote! {}); + .unwrap_or_else(proc_macro2::TokenStream::new); let query_string = &self.query_string; let impls = self.build_impls()?; diff --git a/graphql_client_web/src/lib.rs b/graphql_client_web/src/lib.rs index 02a066d73..8dfcce8da 100644 --- a/graphql_client_web/src/lib.rs +++ b/graphql_client_web/src/lib.rs @@ -1,6 +1,6 @@ #![deprecated( - note = "graphql_client_web is deprecated. The web client is now part of the graphql_client crate, with the \"web\" feature." + note = "graphql_client_web is deprecated. The web client is now part of the graphql_client crate, with the default \"client\" feature." )] -pub use graphql_client::web::*; +pub use graphql_client::http::*; pub use graphql_client::{self, *};