Skip to content

Commit

Permalink
feat(graphql): add graphql-over-http client (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
LNSD authored Nov 14, 2023
1 parent 0e884b0 commit 66c20f3
Show file tree
Hide file tree
Showing 17 changed files with 2,453 additions and 2 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ jobs:
uses: Leafwing-Studios/cargo-cache@5edda26afa3d28be5d6ee87d4c69c246e3ee37fb # v1

- name: Unit tests
run: cargo test --verbose --workspace --lib -- --nocapture
run: cargo test --verbose --workspace --all-features --lib -- --nocapture

- name: Integration tests
run: cargo test --verbose --workspace --tests '*' -- --nocapture
run: cargo test --verbose --workspace --all-features --tests '*' -- --nocapture
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ resolver = "2"
members = [
"toolshed",
"graphql",
"graphql-http"
]
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@ services.
```toml
graphql = { git = "https://github.com/edgeandnode/toolshed", tag = "graphql-v0.1.0" }
```
* **graphql-http:** A _reqwest_ based GraphQL-over-HTTP client.

```toml
graphql-http = { git = "https://github.com/eandn/toolshed", tag = "graphql-http-v0.1.0" }
```
26 changes: 26 additions & 0 deletions graphql-http/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
name = "graphql-http"
description = "A reqwest based GraphQL-over-HTTP client"
version = "0.1.0"
edition = "2021"
rust-version = "1.63.0"

[features]
http-reqwest = ["dep:async-trait", "dep:thiserror", "dep:reqwest"]
compat-graphql-client = ["dep:graphql_client"]
compat-graphql-parser = ["dep:graphql-parser"]

[dependencies]
anyhow = "1.0.75"
async-trait = { version = "0.1", optional = true }
graphql-parser = { version = "0.4.0", optional = true }
graphql_client = { version = "0.13.0", optional = true }
reqwest = { version = "0.11", optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = { version = "1.0", optional = true }

[dev-dependencies]
assert_matches = "1.5.0"
indoc = "2.0.4"
tokio = { version = "1.32", features = ["rt", "macros", "time"] }
9 changes: 9 additions & 0 deletions graphql-http/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
GraphQL-over-HTTP
=================

A _reqwest_ based GraphQL-over-HTTP client compatible with `graphql-parser` and `graphql-client` crates. See the
crate documentation for more details.

```
graphql-http = { git = "https://github.com/edgeandnode/toolshed", tag = "graphql-http-vX.Y.Z" }
```
57 changes: 57 additions & 0 deletions graphql-http/src/compat.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#[cfg(feature = "compat-graphql-parser")]
pub mod compat_graphql_parser {
use graphql_parser::query::Text;

use crate::graphql::{Document, IntoDocument};

// Implement `IntoRequestParameters` for `graphql_parser::query::Document` so that we can use
// `graphql_parser` to parse GraphQL queries.
//
// As any type implementing `IntoQuery` also implements `IntoRequestParameters`, this allows us to
// seamlessly support `graphql_parser` generated queries.
impl<'a, T: Text<'a>> IntoDocument for graphql_parser::query::Document<'a, T> {
fn into_document(self) -> Document {
Document::new(self.to_string())
}
}
}

#[cfg(feature = "compat-graphql-client")]
pub mod compat_graphql_client {
use graphql_client::QueryBody;

use crate::graphql::IntoDocument;
use crate::http::request::{IntoRequestParameters, RequestParameters};

// Implement `IntoRequestParameters` for `graphql_client::QueryBody` so that we can seamlessly
// support `graphql_client` generated queries.
impl<V> IntoRequestParameters for QueryBody<V>
where
V: serde::ser::Serialize,
{
fn into_request_parameters(self) -> RequestParameters {
let query = self.query.into_document();

// Do not send the `operation_name` field if it is empty.
let operation_name = if !self.operation_name.is_empty() {
Some(self.operation_name.to_owned())
} else {
None
};

// Do not send the `variables` field if the json serialization fails, or if the
// serialization result is not a JSON object.
let variables = match serde_json::to_value(self.variables) {
Ok(serde_json::Value::Object(vars)) => Some(vars),
_ => None,
};

RequestParameters {
query,
operation_name,
variables: variables.unwrap_or_default(),
extensions: Default::default(),
}
}
}
}
85 changes: 85 additions & 0 deletions graphql-http/src/graphql.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//! GraphQL query type and related traits.
//!
//! This module contains the [`Document`] type and the [`IntoDocument`] conversion trait. The conversion
//! trait is implemented for string types: [`String`] and `&str`.
//!

/// A (raw) GraphQL request document.
///
/// This type is a wrapper around a string that represents a GraphQL request document. This type
/// does not perform any validation on the string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Document(String);

impl Document {
/// Create a new GraphQL [`Document`] instance from a `String`.
pub fn new(value: String) -> Self {
Self(value)
}

/// Return a string slice to the document.
pub fn as_str(&self) -> &str {
&self.0
}
}

impl From<String> for Document {
fn from(value: String) -> Self {
Self::new(value)
}
}

impl std::fmt::Display for Document {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}

impl serde::ser::Serialize for Document {
fn serialize<S: serde::ser::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}

/// A trait for types that can be converted into a [`Document`].
pub trait IntoDocument {
/// Consumes `self` and returns a [`Document`].
fn into_document(self) -> Document;
}

/// A trait for types that can be converted into a [`Document`] and variables tuple.
pub trait IntoDocumentWithVariables {
type Variables: serde::Serialize;

/// Consumes `self` and returns a query and variables tuple.
fn into_document_with_variables(self) -> (Document, Self::Variables);
}

impl IntoDocument for Document {
fn into_document(self) -> Document {
self
}
}

impl IntoDocument for String {
fn into_document(self) -> Document {
Document(self)
}
}

impl IntoDocument for &str {
fn into_document(self) -> Document {
Document(self.to_owned())
}
}

impl<T> IntoDocumentWithVariables for T
where
T: IntoDocument,
{
type Variables = ();

fn into_document_with_variables(self) -> (Document, Self::Variables) {
(self.into_document(), ())
}
}
2 changes: 2 additions & 0 deletions graphql-http/src/http.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod request;
pub mod response;
66 changes: 66 additions & 0 deletions graphql-http/src/http/request.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
use crate::graphql::{Document, IntoDocumentWithVariables};

/// The media type for GraphQL-over-HTTP requests. As specified in the section
/// [4.1 Media Types](https://graphql.github.io/graphql-over-http/draft/#sec-Media-Types)
/// of the GraphQL-over-HTTP specification.
pub const GRAPHQL_REQUEST_MEDIA_TYPE: &str = "application/json";

/// The parameters of a GraphQL-over-HTTP request.
///
/// As specified in the section [5.1 Request Parameters](https://graphql.github.io/graphql-over-http/draft/#sec-Request-Parameters)
/// of the GraphQL-over-HTTP specification.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct RequestParameters {
/// The string representation of the Source Text of a GraphQL Document as specified in [the
/// Language section of the GraphQL specification](https://spec.graphql.org/draft/#sec-Language).
pub query: Document,

/// Optional name of the operation in the Document to execute.
#[serde(rename = "operationName", skip_serializing_if = "Option::is_none")]
pub operation_name: Option<String>,

/// Values for any Variables defined by the Operation.
#[serde(skip_serializing_if = "serde_json::Map::is_empty")]
pub variables: serde_json::Map<String, serde_json::Value>,

/// Reserved for implementors to extend the protocol however they see fit.
#[serde(skip_serializing_if = "serde_json::Map::is_empty")]
pub extensions: serde_json::Map<String, serde_json::Value>,
}

/// Convert `self` into a `RequestParameters` struct.
pub trait IntoRequestParameters {
/// Consumes `self` and returns a `RequestParameters` struct.
fn into_request_parameters(self) -> RequestParameters;
}

impl IntoRequestParameters for RequestParameters {
fn into_request_parameters(self) -> RequestParameters {
self
}
}

// Any type implementing `IntoQueryWithVariables` (or `IntoQuery`) can be converted into
// `RequestParameters`.
impl<T> IntoRequestParameters for T
where
T: IntoDocumentWithVariables,
{
fn into_request_parameters(self) -> RequestParameters {
let (query, variables) = self.into_document_with_variables();

// Do not send the `variables` field if the json serialization fails, or if the
// serialization result is not a JSON object.
let variables = match serde_json::to_value(variables) {
Ok(serde_json::Value::Object(vars)) => Some(vars),
_ => None,
};

RequestParameters {
query,
operation_name: None,
variables: variables.unwrap_or_default(),
extensions: Default::default(),
}
}
}
88 changes: 88 additions & 0 deletions graphql-http/src/http/response.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/// The preferred type for GraphQL-over-HTTP server responses. As specified in the section
/// [4.1 Media Types](https://graphql.github.io/graphql-over-http/draft/#sec-Media-Types) of the
/// GraphQL-over-HTTP specification.
pub const GRAPHQL_RESPONSE_MEDIA_TYPE: &str = "application/graphql-response+json";

/// The legacy type for GraphQL-over-HTTP server responses. As specified in the section
/// [4.1 Media Types](https://graphql.github.io/graphql-over-http/draft/#sec-Media-Types) of the
/// GraphQL-over-HTTP specification.
pub const GRAPHQL_LEGACY_RESPONSE_MEDIA_TYPE: &str = "application/json";

/// The response error type for GraphQL-over-HTTP server responses. As specified in the section
/// [7.1.2 Errors](https://spec.graphql.org/draft/#sec-Errors) and the
/// [Error Result Format](https://spec.graphql.org/draft/#sec-Errors.Error-Result-Format) subsection
/// of the GraphQL specification.
#[derive(Debug, serde::Deserialize)]
pub struct Error {
/// A short, human-readable description of the problem.
///
/// From the [Error Result Format](https://spec.graphql.org/draft/#sec-Errors.Error-Result-Format)
/// subsection of the GraphQL specification:
///
/// > Every error MUST contain an entry with the key `message` with a string description of the
/// > error intended for the developer as a guide to understand and correct the error.
pub message: String,

/// A list of locations describing the beginning of the associated syntax element causing the
/// error.
///
/// From the [Error Result Format](https://spec.graphql.org/draft/#sec-Errors.Error-Result-Format)
/// subsection of the GraphQL specification:
///
/// > If an error can be associated to a particular point in the requested GraphQL document, it
/// > SHOULD contain an entry with the key `locations` with a list of locations, where each
/// > location is a map with the keys `line` and `column`, both positive numbers starting from
/// `1` which describe the beginning of an associated syntax element.
#[serde(default)]
pub locations: Vec<ErrorLocation>,

/// A list of path segments starting at the root of the response and ending with the field
/// associated with the error.
///
/// From the [Error Result Format](https://spec.graphql.org/draft/#sec-Errors.Error-Result-Format)
/// subsection of the GraphQL specification:
///
/// > If an error can be associated to a particular field in the GraphQL result, it must contain
/// > an entry with the key `path` that details the path of the response field which experienced
/// > the error. This allows clients to identify whether a `null` result is intentional or
/// > caused by a runtime error.
/// >
/// > This field should be a list of path segments starting at the root of the response and
/// > ending with the field associated with the error. Path segments that represent fields
/// > should be strings, and path segments that represent list indices should be 0-indexed
/// > integers. If the error happens in an aliased field, the path to the error should use the
/// > aliased name, since it represents a path in the response, not in the request.
#[serde(default)]
pub path: Vec<String>,
}

/// A location describing the beginning of the associated syntax element causing the error.
#[derive(Debug, serde::Deserialize)]
pub struct ErrorLocation {
pub line: usize,
pub column: usize,
}

/// A response to a GraphQL request.
///
/// As specified in the section [7. Response](https://spec.graphql.org/draft/#sec-Response) of the
/// GraphQL specification.
#[derive(Debug, serde::Deserialize)]
pub struct ResponseBody<T> {
/// The e response will be the result of the execution of the requested operation.
///
/// If the operation was a query, this output will be an object of the query root operation
/// type; if the operation was a mutation, this output will be an object of the mutation root
/// operation type.
///
/// If an error was raised before execution begins, the data entry should not be present in the
/// result; If an error was raised during the execution that prevented a valid response, the
/// data entry in the response should be `null`. In both cases the field will be set to `None`.
pub data: Option<T>,

/// The errors entry in the response is a non-empty list of [`Error`] raised during the request,
/// where each error is a map of data described by the error result specified in the section
/// [7.1.2. Errors](https://spec.graphql.org/draft/#sec-Errors) of the GraphQL specification.
#[serde(default)]
pub errors: Vec<Error>,
}
Loading

0 comments on commit 66c20f3

Please sign in to comment.