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}