Skip to main content

hey_sdk/
form.rs

1//! Talking to HEY's form-backed endpoints, and what they answer with. Several parts of HEY
2//! have no JSON surface: a workflow is created by posting a form and reading the record back
3//! out of the redirect the browser would have followed.
4//!
5//! [`Client::form`] builds the request such an endpoint expects and [`Client::send_form`]
6//! sends it. The raw verbs — [`Client::post_form`] and its neighbours — are those two
7//! together for the common shapes.
8
9use url::Url;
10
11use crate::client::{Client, Response};
12use crate::error::Error;
13use crate::http::{Method, StatusCode};
14use crate::operation::Operation;
15
16impl Client {
17    /// A request to one of the endpoints HEY serves only as a browser form: the path as the
18    /// caller wrote it, a browser's `Accept`, and the redirect taken for the answer rather
19    /// than followed.
20    ///
21    /// It is not retried, whatever its method — a form post that may already have gone
22    /// through is not one to repeat on a timeout or a 503. A 401 is the exception the whole
23    /// client makes: credentials are refreshed and the request goes out once more, since a
24    /// request HEY refused for want of a token never reached the write it would repeat.
25    ///
26    /// The model describes none of these paths, so say what the call means with
27    /// [`Operation::info`] before sending it, or the hooks will only hear that something raw
28    /// went out. [`crate::services::write_info`] builds that.
29    ///
30    /// A path that already is a URL is checked before it is taken: HTTPS goes anywhere,
31    /// plain HTTP only back to the base URL's own host. That check is what this can fail on.
32    pub fn form(&self, method: Method, path: &str) -> Result<Operation, Error> {
33        let mut operation = self.raw(method, path)?;
34        operation
35            .form_representation()
36            .capture_redirects()
37            .idempotent(false);
38        Ok(operation)
39    }
40
41    /// Sends a form request and reads the redirect it answered with.
42    ///
43    /// A failure keeps the code, hint and request id HEY answered with. Go flattens every
44    /// status but 401 into a bare "Form request failed (HTTP 503)", which loses the request
45    /// id support would look the call up by and tells a caller nothing it could act on; a
46    /// 503 here still reads as the retryable API error it is.
47    pub async fn send_form(&self, operation: Operation) -> Result<FormResponse, Error> {
48        let response = self.execute(operation).await?;
49        Ok(FormResponse::new(&response))
50    }
51}
52
53/// The answer to a form or multipart request. A redirect is captured rather than followed,
54/// so a 302 or 303 arrives here with its `Location` intact; an endpoint reached on a
55/// `.json` path answers the record itself, which lands in `body` instead.
56#[derive(Debug, Clone, PartialEq, Eq)]
57#[non_exhaustive]
58pub struct FormResponse {
59    /// Where the redirect pointed, exactly as HEY wrote it — often a path rather than a
60    /// whole URL.
61    pub location: Option<String>,
62    /// The status the endpoint answered: a 302 or 303 for a redirect, a 200 for a document.
63    pub status: StatusCode,
64    /// What the endpoint answered when it answered a document instead of a redirect.
65    pub body: String,
66}
67
68impl FormResponse {
69    pub(crate) fn new(response: &Response) -> FormResponse {
70        let mut location = None;
71        let mut body = String::new();
72        if response.status.is_redirection() {
73            location = response.header("location").map(String::from);
74        } else {
75            body = String::from_utf8_lossy(&response.body).into_owned();
76        }
77        FormResponse {
78            location,
79            status: response.status,
80            body,
81        }
82    }
83
84    /// The id of the record the redirect named: the rightmost path segment that reads as a
85    /// number, so `/calendar/events/42` and `/calendar/events/42/edit` both answer 42.
86    pub fn extract_id(&self) -> Result<i64, Error> {
87        let location = self
88            .location
89            .as_deref()
90            .filter(|location| !location.is_empty())
91            .ok_or_else(|| Error::api(0, "no location header in response"))?;
92        let path = match Url::parse(location) {
93            Ok(url) => url.path().to_string(),
94            Err(url::ParseError::RelativeUrlWithoutBase) => location
95                .split(['?', '#'])
96                .next()
97                .unwrap_or_default()
98                .to_string(),
99            Err(error) => {
100                return Err(Error::api(
101                    0,
102                    format!("failed to parse location URL: {error}"),
103                ));
104            }
105        };
106        path.trim_end_matches('/')
107            .rsplit('/')
108            .find_map(|segment| segment.parse().ok())
109            .ok_or_else(|| Error::api(0, format!("no numeric ID found in location: {location}")))
110    }
111}