thegraph_graphql_http/
http_client.rs

1//! HTTP client extensions.
2
3#[cfg(feature = "reqwest")]
4#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
5pub use reqwest_ext::ReqwestExt;
6
7use crate::http::response::{Error, ResponseBody};
8
9/// The error type returned by `ReqwestExt`
10#[cfg(feature = "reqwest")]
11#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
12#[derive(thiserror::Error, Debug)]
13pub enum RequestError {
14    /// An error occurred while serializing the GraphQL request.
15    #[error("Error serializing GraphQL request parameters: {0}")]
16    RequestSerializationError(serde_json::Error),
17
18    /// An error occurred while making the HTTP request.
19    #[error("Error making HTTP request: {0}")]
20    RequestSendError(#[from] reqwest::Error),
21
22    /// An error occurred while receiving the HTTP response.
23    #[error("Error receiving HTTP response ({0}): {1}")]
24    ResponseRecvError(reqwest::StatusCode, String),
25
26    /// An error occurred while deserializing the GraphQL response.
27    #[error(
28        "Error deserializing GraphQL response. Unexpected response: {response}. Error: {error}"
29    )]
30    ResponseDeserializationError {
31        error: serde_json::Error,
32        response: String,
33    },
34}
35
36/// The possible errors results of a GraphQL-over-HTTP response.
37#[derive(thiserror::Error, Debug)]
38pub enum ResponseError {
39    /// The GraphQL response is empty.
40    #[error("Empty response")]
41    Empty,
42
43    /// The GraphQL request failed.
44    #[error("GraphQL request failed: {errors:?}")]
45    Failure {
46        /// A list of errors returned by the server.
47        errors: Vec<Error>,
48    },
49}
50
51/// The result type of GraphQL-over-HTTP request.
52pub type ResponseResult<ResponseData> = Result<ResponseData, ResponseError>;
53
54/// Process the GraphQL response body.
55fn process_response_body<ResponseData>(
56    resp: ResponseBody<ResponseData>,
57) -> ResponseResult<ResponseData>
58where
59    ResponseData: serde::de::DeserializeOwned,
60{
61    // [7.1.2 Errors](https://spec.graphql.org/draft/#sec-Errors)
62    //
63    // > If present, the `errors` entry in the response must contain at least one error. If no
64    // > `errors` were raised during the request, the errors entry must not be present in the
65    // > result.
66    //
67    // > If the `data` entry in the response is not present, the `errors` entry MUST be present. It
68    // > MUST contain at least one _request error_ indicating why no data was able to be returned.
69    //
70    // > If the data entry in the response is present (including if it is the value **null**), the
71    // > `errors` entry MUST be present if and only if one or more _field error_ was raised during
72    // > execution.
73    match (resp.data, resp.errors) {
74        (Some(data), errors) if errors.is_empty() => Ok(data),
75        (None, errors) if errors.is_empty() => Err(ResponseError::Empty),
76        // Do not consider partial responses
77        (_, errors) => Err(ResponseError::Failure { errors }),
78    }
79}
80
81#[cfg(feature = "reqwest")]
82mod reqwest_ext {
83    use async_trait::async_trait;
84    use reqwest::header::{ACCEPT, CONTENT_TYPE};
85
86    use super::{RequestError, ResponseResult, process_response_body};
87    use crate::http::{
88        request::{GRAPHQL_REQUEST_MEDIA_TYPE, IntoRequestParameters},
89        response::{GRAPHQL_LEGACY_RESPONSE_MEDIA_TYPE, GRAPHQL_RESPONSE_MEDIA_TYPE},
90    };
91
92    /// An extension trait for reqwest::RequestBuilder.
93    #[cfg(feature = "reqwest")]
94    #[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
95    #[async_trait]
96    pub trait ReqwestExt {
97        /// Sets the `Content-Type` and `Accept` headers to the GraphQL-over-HTTP media types and
98        /// serializes the GraphQL request.
99        ///
100        /// If the GraphQL request cannot be serialized, an error is returned.
101        fn graphql(self, req: impl IntoRequestParameters) -> Result<Self, serde_json::Error>
102        where
103            Self: Sized;
104
105        /// Runs a GraphQL query with the parameters in RequestBuilder, deserializes
106        /// the body and returns the result.
107        async fn send_graphql<ResponseData>(
108            self,
109            req: impl IntoRequestParameters + Send,
110        ) -> Result<ResponseResult<ResponseData>, RequestError>
111        where
112            ResponseData: serde::de::DeserializeOwned;
113    }
114
115    #[cfg(feature = "reqwest")]
116    #[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
117    #[async_trait]
118    impl ReqwestExt for reqwest::RequestBuilder {
119        fn graphql(self, req: impl IntoRequestParameters) -> Result<Self, serde_json::Error>
120        where
121            Self: Sized,
122        {
123            let gql_request = req.into_request_parameters();
124            let gql_request_body = serde_json::to_vec(&gql_request)?;
125
126            let builder = self
127                // Set `Content-Type` header to `application/json` as specified in the section
128                // [5.4 POST](https://graphql.github.io/graphql-over-http/draft/#sec-POST) of the
129                // GraphQL-over-HTTP specification.
130                .header(CONTENT_TYPE, GRAPHQL_REQUEST_MEDIA_TYPE)
131                // Set `Accept` header to `application/json` and `application/graphql-response+json` to
132                // support both the legacy and the current GraphQL-over-HTTP media types. As specified in
133                // the section [5.2.1 Legacy Watershed](https://graphql.github.io/graphql-over-http/draft/#sec-Legacy-Watershed)
134                // of the GraphQL-over-HTTP specification.
135                .header(
136                    ACCEPT,
137                    format!(
138                        "{GRAPHQL_RESPONSE_MEDIA_TYPE}; charset=utf-8, {GRAPHQL_LEGACY_RESPONSE_MEDIA_TYPE}; charset=utf-8"
139                    ),
140                )
141                .body(gql_request_body);
142
143            Ok(builder)
144        }
145
146        async fn send_graphql<ResponseData>(
147            self,
148            req: impl IntoRequestParameters + Send,
149        ) -> Result<ResponseResult<ResponseData>, RequestError>
150        where
151            ResponseData: serde::de::DeserializeOwned,
152        {
153            let builder = self
154                .graphql(req)
155                .map_err(RequestError::RequestSerializationError)?;
156
157            match builder.send().await {
158                Ok(response) => {
159                    // Process a GraphQL-over-HTTP response.
160                    if !is_legacy_response(&response) {
161                        process_graphql_response(response).await
162                    } else {
163                        process_legacy_graphql_response(response).await
164                    }
165                }
166                Err(e) => Err(RequestError::RequestSendError(e)),
167            }
168        }
169    }
170
171    /// Determine if the response is a GraphQL-over-HTTP response using the legacy media type.
172    fn is_legacy_response(response: &reqwest::Response) -> bool {
173        let content_type = response.headers().get(CONTENT_TYPE);
174        match content_type {
175            // If the `Content-Type` header is present, check if it is the legacy response media
176            // type or the current GraphQL-over-HTTP response media type.
177            Some(header) => header
178                .as_bytes()
179                .eq_ignore_ascii_case(GRAPHQL_LEGACY_RESPONSE_MEDIA_TYPE.as_bytes()),
180            // If no `Content-Type` header is present, the response SHOULD be interpreted as if the
181            // header field had the value `application/json` (legacy media type).
182            None => true,
183        }
184    }
185
186    /// Process the GraphQL-over-HTTP response when the media type, `application/graphql-response+json`,
187    /// is used.
188    ///
189    /// See the section [6.4.2 application/graphql-response+json](
190    /// https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json)
191    /// of the GraphQL-over-HTTP specification for more information.
192    async fn process_graphql_response<ResponseData>(
193        resp: reqwest::Response,
194    ) -> Result<ResponseResult<ResponseData>, RequestError>
195    where
196        ResponseData: serde::de::DeserializeOwned,
197    {
198        // TODO: Add support for the GraphQL-over-HTTP response media type (non-legacy)
199        //  Fall back to legacy media type for now.
200        process_legacy_graphql_response(resp).await
201    }
202
203    /// Process the GraphQL-over-HTTP response when the legacy media type, `application/json`, is used.
204    ///
205    /// See the section [6.4.1 application/json](https://graphql.github.io/graphql-over-http/draft/#sec-application-json)
206    /// of the GraphQL-over-HTTP specification for more information.
207    async fn process_legacy_graphql_response<ResponseData>(
208        resp: reqwest::Response,
209    ) -> Result<ResponseResult<ResponseData>, RequestError>
210    where
211        ResponseData: serde::de::DeserializeOwned,
212    {
213        let status = resp.status();
214
215        // [6.4.1 application/json](https://graphql.github.io/graphql-over-http/draft/#sec-application-json)
216        //
217        // > The server SHOULD use the 200 status code for every response to a well-formed
218        // > GraphQL-over-HTTP request, independent of any GraphQL request error or GraphQL field error
219        // > raised.
220        //
221        // > For compatibility with legacy servers, this specification allows the use of `4xx` or `5xx`
222        // > status codes for a failed well-formed GraphQL-over-HTTP request where the response uses
223        // > the `application/json` media type, but it is **strongly discouraged**.
224        if !status.is_success() && !status.is_client_error() && !status.is_server_error() {
225            return Err(RequestError::ResponseRecvError(
226                status,
227                resp.text()
228                    .await
229                    .unwrap_or_else(|_| "Empty response body".to_string()),
230            ));
231        }
232
233        // Receive the response body.
234        let response = resp.bytes().await.map_err(|err| {
235            RequestError::ResponseRecvError(status, format!("Error reading response body: {err}"))
236        })?;
237
238        if response.is_empty() {
239            return Err(RequestError::ResponseRecvError(
240                status,
241                "Empty response body".to_string(),
242            ));
243        }
244
245        // Deserialize the response body.
246        let response = serde_json::from_slice(&response).map_err(|error| {
247            RequestError::ResponseDeserializationError {
248                error,
249                response: String::from_utf8_lossy(&response).to_string(),
250            }
251        })?;
252
253        Ok(process_response_body(response))
254    }
255}