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}