├── .gitignore ├── rustfmt.toml ├── .editorconfig ├── rust-toolchain.toml ├── tests ├── reqwest.rs ├── structs.rs ├── errors.rs └── queries.rs ├── .github └── workflows │ ├── release.yml │ └── check.yml ├── Cargo.toml ├── LICENSE ├── CHANGELOG.md ├── src ├── types.rs ├── error.rs ├── lib.rs └── client.rs ├── CODE_OF_CONDUCT.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .idea -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | tab_spaces = 2 2 | hard_tabs = false -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = false 9 | max_line_length = 120 10 | tab_width = 2 11 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | components = ["cargo", "clippy", "rustc", "rustfmt", "rust-src"] 4 | profile = "minimal" 5 | targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"] 6 | -------------------------------------------------------------------------------- /tests/reqwest.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Url; 2 | use std::str::FromStr; 3 | 4 | #[test] 5 | fn test_url() { 6 | let url_raw = "https://subql.darwinia.network/subql-bridger-darwinia"; 7 | let url = Url::from_str(url_raw).unwrap(); 8 | let schema = url.scheme(); 9 | let host = url.host().unwrap(); 10 | assert_eq!( 11 | "https://subql.darwinia.network", 12 | format!("{}://{}", schema, host) 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | 7 | jobs: 8 | publish: 9 | name: Publish 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Package 15 | run: cargo package 16 | 17 | - name: Publish 18 | run: | 19 | cargo login ${{ secrets.TOKEN_CRATES_IO }} 20 | cargo publish 21 | -------------------------------------------------------------------------------- /tests/structs.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Deserialize, Debug)] 4 | #[allow(dead_code)] 5 | pub struct NodeList { 6 | pub data: Vec, 7 | } 8 | 9 | #[derive(Deserialize, Debug)] 10 | #[allow(dead_code)] 11 | pub struct Post { 12 | pub id: String, 13 | } 14 | 15 | #[derive(Deserialize, Debug)] 16 | #[allow(dead_code)] 17 | pub struct SinglePost { 18 | pub post: Post, 19 | } 20 | 21 | #[derive(Deserialize, Debug)] 22 | #[allow(dead_code)] 23 | pub struct AllPosts { 24 | pub posts: NodeList, 25 | } 26 | 27 | pub mod inputs { 28 | use serde::Serialize; 29 | 30 | #[derive(Serialize, Debug)] 31 | pub struct SinglePostVariables { 32 | pub id: u32, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | branches: [master] 7 | 8 | jobs: 9 | cargo-clippy: 10 | name: Cargo clippy 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Clippy 16 | run: cargo clippy --all -- -D warnings 17 | 18 | cargo-test: 19 | name: Cargo test std 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | 24 | - name: Test 25 | run: cargo test 26 | 27 | check-wasm32: 28 | name: Cargo test wasm32 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v2 32 | 33 | - name: Check wasm32 34 | run: cargo check --target wasm32-unknown-unknown 35 | 36 | 37 | -------------------------------------------------------------------------------- /tests/errors.rs: -------------------------------------------------------------------------------- 1 | mod structs; 2 | 3 | use crate::structs::{inputs::SinglePostVariables, SinglePost}; 4 | use gql_client::Client; 5 | 6 | // Initialize endpoint 7 | const ENDPOINT: &str = "https://graphqlzero.almansi.me/api"; 8 | 9 | #[tokio::test] 10 | pub async fn properly_parses_json_errors() { 11 | let client = Client::new(ENDPOINT); 12 | 13 | // Send incorrect query 14 | let query = r#" 15 | query SinglePostQuery($id: ID!) { 16 | post(id: $id) { 17 | id1 18 | } 19 | } 20 | "#; 21 | 22 | let variables = SinglePostVariables { id: 2 }; 23 | let errors = client 24 | .query_with_vars_unwrap::(query, variables) 25 | .await 26 | .err(); 27 | 28 | assert!(errors.is_some()); 29 | let err_data = errors.unwrap(); 30 | let err_json = err_data.json().map(|v| v.len()).unwrap_or_default(); 31 | assert!(err_json > 0usize); 32 | } 33 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gql_client" 3 | version = "1.0.8" 4 | authors = ["Arthur Khlghatyan "] 5 | edition = "2018" 6 | description = "Minimal GraphQL client for Rust" 7 | readme = "README.md" 8 | homepage = "https://github.com/arthurkhlghatyan/gql-client-rs" 9 | repository = "https://github.com/arthurkhlghatyan/gql-client-rs" 10 | license = "MIT" 11 | keywords = ["graphql", "client", "async", "web", "http"] 12 | categories = ["web-programming", "asynchronous"] 13 | 14 | [badges] 15 | maintenance = { status = "actively-developed" } 16 | 17 | [features] 18 | default = ["native-tls"] 19 | 20 | native-tls = ["reqwest/native-tls"] 21 | rustls-tls = ["reqwest/rustls-tls"] 22 | 23 | [dependencies] 24 | serde = { version = "1.0", features = ["derive"] } 25 | serde_json = "1.0" 26 | reqwest = { version = "0.12", features = ["json"], default_features = false } 27 | log = "0.4" 28 | 29 | [dev-dependencies] 30 | tokio = { version = "1", features = ["full"] } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Arthur Khlghatyan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Change Log 3 | 4 | Project Changelog. Starts from version 0.2.1 5 | 6 | ## [1.0.1] - 2021-13-04 7 | 8 | Minor bug fix. 9 | 10 | ### Changed 11 | 12 | - Fixed failing compilation with `serde::export` ([change occured in this commit](https://github.com/serde-rs/serde/commit/dd1f4b483ee204d58465064f6e5bf5a457543b54)) 13 | 14 | ## [1.0.0] - 2021-06-01 15 | 16 | Release V1 17 | 18 | ### Added 19 | 20 | - Configured CI to ensure WebAssembly support 21 | - Added missing path property GraphQLErrorMessage struct 22 | - Upgrade tokio to v1.0 in dev dependencies 23 | - Upgrade reqwest to v0.11 24 | 25 | ### Changed 26 | 27 | - Made location, extensions fields optional in GraphQLErrorMessage struct 28 | 29 | ## [0.2.1] - 2020-12-13 30 | 31 | Added proper error reporting. 32 | 33 | ### Added 34 | 35 | - json function in GraphQLError for accessing JSON errors 36 | - New tests for error reporting 37 | - Restructured tests crate 38 | 39 | ### Changed 40 | 41 | - Changed 'static lifetime for an endpoint, query parameters in client struct to 'a lifetime 42 | - Changed Debug, Display implementations for GraphQLError to properly display error messages -------------------------------------------------------------------------------- /tests/queries.rs: -------------------------------------------------------------------------------- 1 | mod structs; 2 | 3 | use crate::structs::{inputs::SinglePostVariables, AllPosts, SinglePost}; 4 | use gql_client::Client; 5 | use std::collections::HashMap; 6 | 7 | // Initialize endpoint 8 | const ENDPOINT: &str = "https://graphqlzero.almansi.me/api"; 9 | 10 | #[tokio::test] 11 | pub async fn fetches_one_post() { 12 | let client = Client::new(ENDPOINT); 13 | 14 | let query = r#" 15 | query SinglePostQuery($id: ID!) { 16 | post(id: $id) { 17 | id 18 | } 19 | } 20 | "#; 21 | 22 | let variables = SinglePostVariables { id: 2 }; 23 | let data = client 24 | .query_with_vars_unwrap::(query, variables) 25 | .await 26 | .unwrap(); 27 | 28 | assert_eq!(data.post.id, String::from("2"), "Post id retrieved 2"); 29 | } 30 | 31 | #[tokio::test] 32 | pub async fn fetches_all_posts() { 33 | let mut headers = HashMap::new(); 34 | headers.insert("content-type", "application/json"); 35 | 36 | let client = Client::new_with_headers(ENDPOINT, headers); 37 | 38 | let query = r#" 39 | query AllPostsQuery { 40 | posts { 41 | data { 42 | id 43 | } 44 | } 45 | } 46 | "#; 47 | 48 | let data: AllPosts = client.query_unwrap::(query).await.unwrap(); 49 | 50 | assert!(!data.posts.data.is_empty()); 51 | } 52 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | #[cfg(not(target_arch = "wasm32"))] 3 | use std::convert::TryFrom; 4 | 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[cfg(not(target_arch = "wasm32"))] 8 | use crate::GraphQLError; 9 | 10 | /// GQL client config 11 | #[derive(Clone, Debug, Deserialize, Serialize)] 12 | pub struct ClientConfig { 13 | /// the endpoint about graphql server 14 | pub endpoint: String, 15 | /// gql query timeout, unit: seconds 16 | pub timeout: Option, 17 | /// additional request header 18 | pub headers: Option>, 19 | /// request proxy 20 | pub proxy: Option, 21 | } 22 | 23 | /// proxy type 24 | #[derive(Clone, Debug, Deserialize, Serialize)] 25 | pub enum ProxyType { 26 | Http, 27 | Https, 28 | All, 29 | } 30 | 31 | /// proxy auth, basic_auth 32 | #[derive(Clone, Debug, Deserialize, Serialize)] 33 | pub struct ProxyAuth { 34 | pub username: String, 35 | pub password: String, 36 | } 37 | 38 | /// request proxy 39 | #[derive(Clone, Debug, Deserialize, Serialize)] 40 | pub struct GQLProxy { 41 | /// schema, proxy url 42 | pub schema: String, 43 | /// proxy type 44 | pub type_: ProxyType, 45 | /// auth 46 | pub auth: Option, 47 | } 48 | 49 | #[cfg(not(target_arch = "wasm32"))] 50 | impl TryFrom for reqwest::Proxy { 51 | type Error = GraphQLError; 52 | 53 | fn try_from(gql_proxy: GQLProxy) -> Result { 54 | let proxy = match gql_proxy.type_ { 55 | ProxyType::Http => reqwest::Proxy::http(gql_proxy.schema), 56 | ProxyType::Https => reqwest::Proxy::https(gql_proxy.schema), 57 | ProxyType::All => reqwest::Proxy::all(gql_proxy.schema), 58 | } 59 | .map_err(|e| Self::Error::with_text(format!("{:?}", e)))?; 60 | Ok(proxy) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fmt::{self, Formatter}; 3 | 4 | use reqwest::Error; 5 | use serde::Deserialize; 6 | 7 | #[derive(Clone)] 8 | pub struct GraphQLError { 9 | message: String, 10 | json: Option>, 11 | } 12 | 13 | // https://spec.graphql.org/June2018/#sec-Errors 14 | #[derive(Deserialize, Debug, Clone)] 15 | #[allow(dead_code)] 16 | pub struct GraphQLErrorMessage { 17 | message: String, 18 | locations: Option>, 19 | extensions: Option>, 20 | path: Option>, 21 | } 22 | 23 | #[derive(Deserialize, Debug, Clone)] 24 | #[allow(dead_code)] 25 | pub struct GraphQLErrorLocation { 26 | line: u32, 27 | column: u32, 28 | } 29 | 30 | #[derive(Deserialize, Debug, Clone)] 31 | #[serde(untagged)] 32 | #[allow(dead_code)] 33 | pub enum GraphQLErrorPathParam { 34 | String(String), 35 | Number(u32), 36 | } 37 | 38 | impl GraphQLError { 39 | pub fn with_text(message: impl AsRef) -> Self { 40 | Self { 41 | message: message.as_ref().to_string(), 42 | json: None, 43 | } 44 | } 45 | 46 | pub fn with_message_and_json(message: impl AsRef, json: Vec) -> Self { 47 | Self { 48 | message: message.as_ref().to_string(), 49 | json: Some(json), 50 | } 51 | } 52 | 53 | pub fn with_json(json: Vec) -> Self { 54 | Self::with_message_and_json("Look at json field for more details", json) 55 | } 56 | 57 | pub fn message(&self) -> &str { 58 | &self.message 59 | } 60 | 61 | pub fn json(&self) -> Option> { 62 | self.json.clone() 63 | } 64 | } 65 | 66 | fn format(err: &GraphQLError, f: &mut Formatter<'_>) -> fmt::Result { 67 | // Print the main error message 68 | writeln!(f, "\nGQLClient Error: {}", err.message)?; 69 | 70 | // Check if query errors have been received 71 | if err.json.is_none() { 72 | return Ok(()); 73 | } 74 | 75 | let errors = err.json.as_ref(); 76 | 77 | for err in errors.unwrap() { 78 | writeln!(f, "Message: {}", err.message)?; 79 | } 80 | 81 | Ok(()) 82 | } 83 | 84 | impl fmt::Display for GraphQLError { 85 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 86 | format(self, f) 87 | } 88 | } 89 | 90 | impl fmt::Debug for GraphQLError { 91 | #[allow(clippy::needless_borrow)] 92 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 93 | format(&self, f) 94 | } 95 | } 96 | 97 | impl From for GraphQLError { 98 | fn from(error: Error) -> Self { 99 | Self { 100 | message: error.to_string(), 101 | json: None, 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at arthur.khlghatyan@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gql_client 2 | 3 | Minimal GraphQL client for Rust 4 | 5 | [![Build Status](https://travis-ci.com/arthurkhlghatyan/gql-client-rs.svg?branch=master)](https://travis-ci.com/arthurkhlghatyan/gql-client-rs) 6 | [![crates.io](https://img.shields.io/crates/v/gql_client.svg)](https://crates.io/crates/gql_client) 7 | [![docs](https://docs.rs/gql_client/badge.svg)](https://docs.rs/gql_client/latest/gql_client/) 8 | 9 | * Simple API, supports queries and mutations 10 | * Does not require schema file for introspection 11 | * Supports WebAssembly 12 | 13 | # Basic Usage 14 | 15 | * Use client.query_with_vars for queries with variables 16 | * There's also a wrapper client.query if there is no need to pass variables 17 | 18 | ```rust 19 | use gql_client::Client; 20 | use serde::{Deserialize, Serialize}; 21 | 22 | #[derive(Deserialize)] 23 | pub struct Data { 24 | user: User 25 | } 26 | 27 | #[derive(Deserialize)] 28 | pub struct User { 29 | id: String, 30 | name: String 31 | } 32 | 33 | #[derive(Serialize)] 34 | pub struct Vars { 35 | id: u32 36 | } 37 | 38 | #[tokio::main] 39 | async fn main() -> Result<(), Box> { 40 | let endpoint = "https://graphqlzero.almansi.me/api"; 41 | let query = r#" 42 | query UserByIdQuery($id: ID!) { 43 | user(id: $id) { 44 | id 45 | name 46 | } 47 | } 48 | "#; 49 | 50 | let client = Client::new(endpoint); 51 | let vars = Vars { id: 1 }; 52 | let data = client.query_with_vars::(query, vars).await.unwrap(); 53 | 54 | println!("Id: {}, Name: {}", data.unwrap().user.id, data.unwrap().user.name); 55 | 56 | Ok(()) 57 | } 58 | ``` 59 | 60 | 61 | # Passing HTTP headers 62 | 63 | Client exposes new_with_headers function to pass headers 64 | using simple HashMap<&str, &str> 65 | 66 | ```rust 67 | use gql_client::Client; 68 | use std::collections::HashMap; 69 | 70 | #[tokio::main] 71 | async fn main() -> Result<(), Box> { 72 | let endpoint = "https://graphqlzero.almansi.me/api"; 73 | let mut headers = HashMap::new(); 74 | headers.insert("authorization", "Bearer "); 75 | 76 | let client = Client::new_with_headers(endpoint, headers); 77 | 78 | Ok(()) 79 | } 80 | ``` 81 | 82 | # Error handling 83 | There are two types of errors that can possibly occur. HTTP related errors (for example, authentication problem) 84 | or GraphQL query errors in JSON response. 85 | Debug, Display implementation of GraphQLError struct properly displays those error messages. 86 | Additionally, you can also look at JSON content for more detailed output by calling err.json() 87 | 88 | ```rust 89 | use gql_client::Client; 90 | use serde::{Deserialize, Serialize}; 91 | 92 | #[derive(Deserialize)] 93 | pub struct Data { 94 | user: User 95 | } 96 | 97 | #[derive(Deserialize)] 98 | pub struct User { 99 | id: String, 100 | name: String 101 | } 102 | 103 | #[derive(Serialize)] 104 | pub struct Vars { 105 | id: u32 106 | } 107 | 108 | #[tokio::main] 109 | async fn main() -> Result<(), Box> { 110 | let endpoint = "https://graphqlzero.almansi.me/api"; 111 | 112 | // Send incorrect request 113 | let query = r#" 114 | query UserByIdQuery($id: ID!) { 115 | user(id: $id) { 116 | id1 117 | name 118 | } 119 | } 120 | "#; 121 | 122 | let client = Client::new(endpoint); 123 | let vars = Vars { id: 1 }; 124 | let error = client.query_with_vars::(query, vars).await.err(); 125 | 126 | println!("{:?}", error); 127 | 128 | Ok(()) 129 | } 130 | ``` 131 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_cfg))] 2 | //! Minimal GraphQL client for rust 3 | //! 4 | //! * Simple API, supports queries and mutations 5 | //! * Does not require schema file for introspection 6 | //! * Supports WebAssembly 7 | //! 8 | //! # Basic Usage 9 | //! 10 | //! * Use client.query_with_vars for queries with variables 11 | //! * There's also a wrapper client.query if there is no need to pass variables 12 | //! 13 | //! ```rust 14 | //!use gql_client::Client; 15 | //!use serde::{Deserialize, Serialize}; 16 | //! 17 | //!#[derive(Deserialize)] 18 | //!pub struct Data { 19 | //! user: User 20 | //!} 21 | //! 22 | //!#[derive(Deserialize)] 23 | //!pub struct User { 24 | //! id: String, 25 | //! name: String 26 | //!} 27 | //! 28 | //!#[derive(Serialize)] 29 | //!pub struct Vars { 30 | //! id: u32 31 | //!} 32 | //! 33 | //!#[tokio::main] 34 | //!async fn main() -> Result<(), Box> { 35 | //! let endpoint = "https://graphqlzero.almansi.me/api"; 36 | //! let query = r#" 37 | //! query UserByIdQuery($id: ID!) { 38 | //! user(id: $id) { 39 | //! id 40 | //! name 41 | //! } 42 | //! } 43 | //! "#; 44 | //! 45 | //! let client = Client::new(endpoint); 46 | //! let vars = Vars { id: 1 }; 47 | //! let data = client.query_with_vars_unwrap::(query, vars).await.unwrap(); 48 | //! 49 | //! println!("Id: {}, Name: {}", data.user.id, data.user.name); 50 | //! 51 | //! Ok(()) 52 | //!} 53 | //! ``` 54 | //! 55 | //! 56 | //! # Passing HTTP headers 57 | //! 58 | //! Client exposes new_with_headers function to pass headers 59 | //! using simple HashMap<&str, &str> 60 | //! 61 | //! ```rust 62 | //!use gql_client::Client; 63 | //!use std::collections::HashMap; 64 | //! 65 | //!#[tokio::main] 66 | //!async fn main() -> Result<(), Box> { 67 | //! let endpoint = "https://graphqlzero.almansi.me/api"; 68 | //! let mut headers = HashMap::new(); 69 | //! headers.insert("authorization", "Bearer "); 70 | //! 71 | //! let client = Client::new_with_headers(endpoint, headers); 72 | //! 73 | //! Ok(()) 74 | //!} 75 | //! ``` 76 | //! 77 | //! # Error handling 78 | //! There are two types of errors that can possibly occur. HTTP related errors (for example, authentication problem) 79 | //! or GraphQL query errors in JSON response. 80 | //! Debug, Display implementation of GraphQLError struct properly displays those error messages. 81 | //! Additionally, you can also look at JSON content for more detailed output by calling err.json() 82 | //! 83 | //! ```rust 84 | //!use gql_client::Client; 85 | //!use serde::{Deserialize, Serialize}; 86 | //! 87 | //!#[derive(Deserialize)] 88 | //!pub struct Data { 89 | //! user: User 90 | //!} 91 | //! 92 | //!#[derive(Deserialize)] 93 | //!pub struct User { 94 | //! id: String, 95 | //! name: String 96 | //!} 97 | //! 98 | //!#[derive(Serialize)] 99 | //!pub struct Vars { 100 | //! id: u32 101 | //!} 102 | //! 103 | //!#[tokio::main] 104 | //!async fn main() -> Result<(), Box> { 105 | //! let endpoint = "https://graphqlzero.almansi.me/api"; 106 | //! 107 | //! // Send incorrect request 108 | //! let query = r#" 109 | //! query UserByIdQuery($id: ID!) { 110 | //! user(id: $id) { 111 | //! id1 112 | //! name 113 | //! } 114 | //! } 115 | //! "#; 116 | //! 117 | //! let client = Client::new(endpoint); 118 | //! let vars = Vars { id: 1 }; 119 | //! let error = client.query_with_vars::(query, vars).await.err(); 120 | //! 121 | //! println!("{:?}", error); 122 | //! 123 | //! Ok(()) 124 | //!} 125 | //! ``` 126 | 127 | mod client; 128 | mod error; 129 | mod types; 130 | 131 | pub use client::GQLClient as Client; 132 | pub use error::GraphQLError; 133 | pub use error::GraphQLErrorMessage; 134 | pub use types::*; 135 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | #[cfg(not(target_arch = "wasm32"))] 3 | use std::convert::TryInto; 4 | use std::str::FromStr; 5 | 6 | use reqwest::{Client, Url}; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | use crate::error::{GraphQLError, GraphQLErrorMessage}; 10 | use crate::ClientConfig; 11 | 12 | #[derive(Clone, Debug)] 13 | pub struct GQLClient { 14 | config: ClientConfig, 15 | } 16 | 17 | #[derive(Serialize)] 18 | struct RequestBody { 19 | query: String, 20 | variables: T, 21 | } 22 | 23 | #[derive(Deserialize, Debug)] 24 | struct GraphQLResponse { 25 | data: Option, 26 | errors: Option>, 27 | } 28 | 29 | impl GQLClient { 30 | #[cfg(target_arch = "wasm32")] 31 | fn client(&self) -> Result { 32 | Ok(Client::new()) 33 | } 34 | 35 | #[cfg(not(target_arch = "wasm32"))] 36 | fn client(&self) -> Result { 37 | let mut builder = Client::builder().timeout(std::time::Duration::from_secs( 38 | self.config.timeout.unwrap_or(5), 39 | )); 40 | if let Some(proxy) = &self.config.proxy { 41 | builder = builder.proxy(proxy.clone().try_into()?); 42 | } 43 | builder 44 | .build() 45 | .map_err(|e| GraphQLError::with_text(format!("Can not create client: {:?}", e))) 46 | } 47 | } 48 | 49 | impl GQLClient { 50 | pub fn new(endpoint: impl AsRef) -> Self { 51 | Self { 52 | config: ClientConfig { 53 | endpoint: endpoint.as_ref().to_string(), 54 | timeout: None, 55 | headers: Default::default(), 56 | proxy: None, 57 | }, 58 | } 59 | } 60 | 61 | pub fn new_with_headers( 62 | endpoint: impl AsRef, 63 | headers: HashMap, 64 | ) -> Self { 65 | let headers: HashMap = headers 66 | .iter() 67 | .map(|(name, value)| (name.to_string(), value.to_string())) 68 | .collect(); 69 | 70 | Self { 71 | config: ClientConfig { 72 | endpoint: endpoint.as_ref().to_string(), 73 | timeout: None, 74 | headers: Some(headers), 75 | proxy: None, 76 | }, 77 | } 78 | } 79 | 80 | pub fn new_with_config(config: ClientConfig) -> Self { 81 | Self { config } 82 | } 83 | } 84 | 85 | impl GQLClient { 86 | pub async fn query(&self, query: &str) -> Result, GraphQLError> 87 | where 88 | K: for<'de> Deserialize<'de>, 89 | { 90 | self.query_with_vars::(query, ()).await 91 | } 92 | 93 | pub async fn query_unwrap(&self, query: &str) -> Result 94 | where 95 | K: for<'de> Deserialize<'de>, 96 | { 97 | self.query_with_vars_unwrap::(query, ()).await 98 | } 99 | 100 | pub async fn query_with_vars_unwrap( 101 | &self, 102 | query: &str, 103 | variables: T, 104 | ) -> Result 105 | where 106 | K: for<'de> Deserialize<'de>, 107 | { 108 | match self.query_with_vars(query, variables).await? { 109 | Some(v) => Ok(v), 110 | None => Err(GraphQLError::with_text(format!( 111 | "No data from graphql server({}) for this query", 112 | self.config.endpoint 113 | ))), 114 | } 115 | } 116 | 117 | pub async fn query_with_vars( 118 | &self, 119 | query: &str, 120 | variables: T, 121 | ) -> Result, GraphQLError> 122 | where 123 | K: for<'de> Deserialize<'de>, 124 | { 125 | self 126 | .query_with_vars_by_endpoint(&self.config.endpoint, query, variables) 127 | .await 128 | } 129 | 130 | async fn query_with_vars_by_endpoint( 131 | &self, 132 | endpoint: impl AsRef, 133 | query: &str, 134 | variables: T, 135 | ) -> Result, GraphQLError> 136 | where 137 | K: for<'de> Deserialize<'de>, 138 | { 139 | let mut times = 1; 140 | let mut endpoint = endpoint.as_ref().to_string(); 141 | let endpoint_url = Url::from_str(&endpoint) 142 | .map_err(|e| GraphQLError::with_text(format!("Wrong endpoint: {}. {:?}", endpoint, e)))?; 143 | let schema = endpoint_url.scheme(); 144 | let host = endpoint_url 145 | .host() 146 | .ok_or_else(|| GraphQLError::with_text(format!("Wrong endpoint: {}", endpoint)))?; 147 | 148 | let client: Client = self.client()?; 149 | let body = RequestBody { 150 | query: query.to_string(), 151 | variables, 152 | }; 153 | 154 | loop { 155 | if times > 10 { 156 | return Err(GraphQLError::with_text(format!( 157 | "Many redirect location: {}", 158 | endpoint 159 | ))); 160 | } 161 | 162 | let mut request = client.post(&endpoint).json(&body); 163 | if let Some(headers) = &self.config.headers { 164 | if !headers.is_empty() { 165 | for (name, value) in headers { 166 | request = request.header(name, value); 167 | } 168 | } 169 | } 170 | 171 | let raw_response = request.send().await?; 172 | if let Some(location) = raw_response.headers().get(reqwest::header::LOCATION) { 173 | let redirect_url = location.to_str().map_err(|e| { 174 | GraphQLError::with_text(format!( 175 | "Failed to parse response header: Location. {:?}", 176 | e 177 | )) 178 | })?; 179 | 180 | // if the response location start with http:// or https:// 181 | if redirect_url.starts_with("http://") || redirect_url.starts_with("https://") { 182 | times += 1; 183 | endpoint = redirect_url.to_string(); 184 | continue; 185 | } 186 | 187 | // without schema 188 | endpoint = if redirect_url.starts_with('/') { 189 | format!("{}://{}{}", schema, host, redirect_url) 190 | } else { 191 | format!("{}://{}/{}", schema, host, redirect_url) 192 | }; 193 | times += 1; 194 | continue; 195 | } 196 | 197 | let status = raw_response.status(); 198 | let response_body_text = raw_response 199 | .text() 200 | .await 201 | .map_err(|e| GraphQLError::with_text(format!("Can not get response: {:?}", e)))?; 202 | 203 | let json: GraphQLResponse = serde_json::from_str(&response_body_text).map_err(|e| { 204 | GraphQLError::with_text(format!( 205 | "Failed to parse response: {:?}. The response body is: {}", 206 | e, response_body_text 207 | )) 208 | })?; 209 | 210 | if !status.is_success() { 211 | return Err(GraphQLError::with_message_and_json( 212 | format!("The response is [{}]", status.as_u16()), 213 | json.errors.unwrap_or_default(), 214 | )); 215 | } 216 | 217 | // Check if error messages have been received 218 | if json.errors.is_some() { 219 | return Err(GraphQLError::with_json(json.errors.unwrap_or_default())); 220 | } 221 | if json.data.is_none() { 222 | log::warn!(target: "gql-client", "The deserialized data is none, the response is: {}", response_body_text); 223 | } 224 | 225 | return Ok(json.data); 226 | } 227 | } 228 | } 229 | --------------------------------------------------------------------------------