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