Skip to main content

io_gmail/v1/
send.rs

1//! HTTP/JSON transport every Gmail coroutine delegates to.
2//!
3//! Builds the authorized request and parses the JSON response, or the
4//! Gmail error envelope on failure.
5//!
6//! Gmail API reference: <https://developers.google.com/gmail/api/reference/rest>.
7
8use core::marker::PhantomData;
9
10use alloc::{
11    string::{String, ToString},
12    vec::Vec,
13};
14
15use io_http::{
16    coroutine::*,
17    rfc6750::bearer::HttpAuthBearer,
18    rfc9110::{
19        request::HttpRequest,
20        send::{HttpSendOutput, HttpSendYield},
21    },
22    rfc9112::send::{Http11Send, Http11SendError},
23};
24use log::{debug, trace};
25use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned};
26use thiserror::Error;
27use url::Url;
28
29use crate::coroutine::*;
30
31/// Base URL of the Gmail REST API v1.
32pub const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1/";
33
34/// Unit marker deserialized from empty 2xx bodies (DELETE, batch
35/// operations, stop).
36#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
37pub struct GmailNoResponse;
38
39impl<'de> Deserialize<'de> for GmailNoResponse {
40    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
41    where
42        D: Deserializer<'de>,
43    {
44        let _ = serde::de::IgnoredAny::deserialize(deserializer)?;
45        Ok(Self)
46    }
47}
48
49/// Errors that can occur during a Gmail exchange.
50#[derive(Debug, Error)]
51pub enum GmailSendError {
52    /// The underlying HTTP exchange failed.
53    #[error("Gmail HTTP request failed: {0}")]
54    Send(#[from] Http11SendError),
55    /// The request body could not be serialized to JSON.
56    #[error("Gmail request serialization failed: {0}")]
57    SerializeRequest(#[source] serde_json::Error),
58    /// The 2xx response body could not be parsed as JSON.
59    #[error("Gmail response parsing failed: {0}")]
60    ParseResponse(#[source] serde_json::Error),
61    /// The request URL could not be built.
62    #[error("Gmail URL parsing failed: {0}")]
63    ParseUrl(#[from] url::ParseError),
64    /// The request was rejected before being sent.
65    #[error("Invalid Gmail request: {0}")]
66    InvalidRequest(String),
67    /// Gmail returned a non-2xx status with its error envelope.
68    #[error("Gmail API returned HTTP {status}: {message}")]
69    Api {
70        /// The effective status code, from the envelope when present.
71        status: u16,
72        /// The error message, from the envelope or the raw body.
73        message: String,
74    },
75    /// The server answered with a redirect, which is never followed.
76    #[error("Gmail server returned an unexpected redirect")]
77    UnexpectedRedirect,
78}
79
80impl GmailSendError {
81    /// Returns the HTTP status code when the error is an API error.
82    pub fn status(&self) -> Option<u16> {
83        match self {
84            Self::Api { status, .. } => Some(*status),
85            _ => None,
86        }
87    }
88
89    /// Whether the error is transient (429 or 5xx) and worth retrying.
90    pub fn is_retryable(&self) -> bool {
91        matches!(self.status(), Some(429 | 500 | 502 | 503 | 504))
92    }
93}
94
95/// Terminal value of a successful Gmail exchange.
96#[derive(Clone, Debug)]
97pub struct GmailSendOutput<T> {
98    /// The parsed 2xx response body.
99    pub response: T,
100    /// Whether the server allows reusing the TCP/TLS connection.
101    pub keep_alive: bool,
102}
103
104/// I/O-free coroutine sending one authorized HTTP request and parsing
105/// the JSON response into `T`.
106pub struct GmailSend<T> {
107    state: State,
108    _phantom: PhantomData<T>,
109}
110
111impl<T: DeserializeOwned> GmailSend<T> {
112    /// Builds a GET request against the given URL.
113    pub fn get(auth: &HttpAuthBearer, url: Url) -> Self {
114        Self::with_method(auth, "GET", url, None, Vec::new())
115    }
116
117    /// Builds a DELETE request against the given URL.
118    pub fn delete(auth: &HttpAuthBearer, url: Url) -> Self {
119        Self::with_method(auth, "DELETE", url, None, Vec::new())
120    }
121
122    /// Builds a POST request with the given value as JSON body.
123    pub fn post_json<B: Serialize>(
124        auth: &HttpAuthBearer,
125        url: Url,
126        body: &B,
127    ) -> Result<Self, GmailSendError> {
128        let body = serde_json::to_vec(body).map_err(GmailSendError::SerializeRequest)?;
129        Ok(Self::with_method(
130            auth,
131            "POST",
132            url,
133            Some("application/json"),
134            body,
135        ))
136    }
137
138    /// Builds a PUT request with the given value as JSON body.
139    pub fn put_json<B: Serialize>(
140        auth: &HttpAuthBearer,
141        url: Url,
142        body: &B,
143    ) -> Result<Self, GmailSendError> {
144        let body = serde_json::to_vec(body).map_err(GmailSendError::SerializeRequest)?;
145        Ok(Self::with_method(
146            auth,
147            "PUT",
148            url,
149            Some("application/json"),
150            body,
151        ))
152    }
153
154    /// Builds a PATCH request with the given value as JSON body.
155    pub fn patch_json<B: Serialize>(
156        auth: &HttpAuthBearer,
157        url: Url,
158        body: &B,
159    ) -> Result<Self, GmailSendError> {
160        let body = serde_json::to_vec(body).map_err(GmailSendError::SerializeRequest)?;
161        Ok(Self::with_method(
162            auth,
163            "PATCH",
164            url,
165            Some("application/json"),
166            body,
167        ))
168    }
169
170    /// Builds a request with an arbitrary method, content type and body.
171    pub fn with_method(
172        auth: &HttpAuthBearer,
173        method: &str,
174        url: Url,
175        content_type: Option<&str>,
176        body: Vec<u8>,
177    ) -> Self {
178        let host = url.host_str().unwrap_or("localhost");
179
180        let mut request = HttpRequest::get(url.clone())
181            .header("Host", host)
182            .header("Accept", "application/json")
183            .header("Authorization", auth.to_authorization())
184            .body(body);
185
186        if let Some(content_type) = content_type {
187            request = request.header("Content-Type", content_type);
188        }
189
190        request.method = method.into();
191
192        debug!("prepare request to send");
193        trace!("method: {method}");
194        trace!("url: {url}");
195
196        Self {
197            state: State::Send(Http11Send::new(request)),
198            _phantom: PhantomData,
199        }
200    }
201}
202
203impl<T: DeserializeOwned> GmailCoroutine for GmailSend<T> {
204    type Yield = GmailYield;
205    type Return = Result<GmailSendOutput<T>, GmailSendError>;
206
207    fn resume(&mut self, arg: Option<&[u8]>) -> GmailCoroutineState<Self::Yield, Self::Return> {
208        match &mut self.state {
209            State::Send(send) => match send.resume(arg) {
210                HttpCoroutineState::Yielded(HttpSendYield::WantsRead) => {
211                    GmailCoroutineState::Yielded(GmailYield::WantsRead)
212                }
213                HttpCoroutineState::Yielded(HttpSendYield::WantsWrite(bytes)) => {
214                    GmailCoroutineState::Yielded(GmailYield::WantsWrite(bytes))
215                }
216                HttpCoroutineState::Yielded(HttpSendYield::WantsRedirect { .. }) => {
217                    GmailCoroutineState::Complete(Err(GmailSendError::UnexpectedRedirect))
218                }
219                HttpCoroutineState::Complete(Err(err)) => {
220                    GmailCoroutineState::Complete(Err(err.into()))
221                }
222                HttpCoroutineState::Complete(Ok(HttpSendOutput {
223                    response,
224                    keep_alive,
225                    ..
226                })) => {
227                    if response.status.is_success() {
228                        // NOTE: an empty 2xx body — a DELETE, or a list
229                        // endpoint with an empty collection (e.g.
230                        // `settings.filters.list`) — is normalised to `{}`.
231                        // `GmailNoResponse` ignores it and struct responses
232                        // fall back to their `#[serde(default)]` fields;
233                        // `null` would fail every struct response with
234                        // "invalid type: null".
235                        let body = if response.body.is_empty() {
236                            b"{}".as_slice()
237                        } else {
238                            response.body.as_slice()
239                        };
240
241                        match serde_json::from_slice::<T>(body) {
242                            Ok(response) => GmailCoroutineState::Complete(Ok(GmailSendOutput {
243                                response,
244                                keep_alive,
245                            })),
246                            Err(err) => GmailCoroutineState::Complete(Err(
247                                GmailSendError::ParseResponse(err),
248                            )),
249                        }
250                    } else {
251                        let (status, message) = parse_api_error(*response.status, &response.body);
252                        GmailCoroutineState::Complete(Err(GmailSendError::Api { status, message }))
253                    }
254                }
255            },
256        }
257    }
258}
259
260enum State {
261    Send(Http11Send),
262}
263
264#[derive(Debug, Deserialize)]
265struct ErrorEnvelope {
266    error: ErrorBody,
267}
268
269#[derive(Debug, Deserialize)]
270struct ErrorBody {
271    code: Option<u16>,
272    message: Option<String>,
273}
274
275/// Parses Gmail's JSON error envelope, falling back to the raw body;
276/// returns the effective status code and message.
277pub fn parse_api_error(http_status: u16, body: &[u8]) -> (u16, String) {
278    if let Ok(envelope) = serde_json::from_slice::<ErrorEnvelope>(body) {
279        let status = envelope.error.code.unwrap_or(http_status);
280        let message = envelope
281            .error
282            .message
283            .filter(|message| !message.trim().is_empty())
284            .unwrap_or_else(|| String::from("unknown Gmail API error"));
285        return (status, message);
286    }
287
288    let message = String::from_utf8_lossy(body).trim().to_string();
289
290    if message.is_empty() {
291        (http_status, String::from("unknown Gmail API error"))
292    } else {
293        (http_status, message)
294    }
295}