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
//! Talking to HEY's form-backed endpoints, and what they answer with. Several parts of HEY
//! have no JSON surface: a workflow is created by posting a form and reading the record back
//! out of the redirect the browser would have followed.
//!
//! [`Client::form`] builds the request such an endpoint expects and [`Client::send_form`]
//! sends it. The raw verbs — [`Client::post_form`] and its neighbours — are those two
//! together for the common shapes.
use url::Url;
use crate::client::{Client, Response};
use crate::error::Error;
use crate::http::{Method, StatusCode};
use crate::operation::Operation;
impl Client {
/// A request to one of the endpoints HEY serves only as a browser form: the path as the
/// caller wrote it, a browser's `Accept`, and the redirect taken for the answer rather
/// than followed.
///
/// It is not retried, whatever its method — a form post that may already have gone
/// through is not one to repeat on a timeout or a 503. A 401 is the exception the whole
/// client makes: credentials are refreshed and the request goes out once more, since a
/// request HEY refused for want of a token never reached the write it would repeat.
///
/// The model describes none of these paths, so say what the call means with
/// [`Operation::info`] before sending it, or the hooks will only hear that something raw
/// went out. [`crate::services::write_info`] builds that.
///
/// A path that already is a URL is checked before it is taken: HTTPS goes anywhere,
/// plain HTTP only back to the base URL's own host. That check is what this can fail on.
pub fn form(&self, method: Method, path: &str) -> Result<Operation, Error> {
let mut operation = self.raw(method, path)?;
operation
.form_representation()
.capture_redirects()
.idempotent(false);
Ok(operation)
}
/// Sends a form request and reads the redirect it answered with.
///
/// A failure keeps the code, hint and request id HEY answered with. Go flattens every
/// status but 401 into a bare "Form request failed (HTTP 503)", which loses the request
/// id support would look the call up by and tells a caller nothing it could act on; a
/// 503 here still reads as the retryable API error it is.
pub async fn send_form(&self, operation: Operation) -> Result<FormResponse, Error> {
let response = self.execute(operation).await?;
Ok(FormResponse::new(&response))
}
}
/// The answer to a form or multipart request. A redirect is captured rather than followed,
/// so a 302 or 303 arrives here with its `Location` intact; an endpoint reached on a
/// `.json` path answers the record itself, which lands in `body` instead.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormResponse {
/// Where the redirect pointed, exactly as HEY wrote it — often a path rather than a
/// whole URL.
pub location: Option<String>,
pub status: StatusCode,
/// What the endpoint answered when it answered a document instead of a redirect.
pub body: String,
}
impl FormResponse {
pub(crate) fn new(response: &Response) -> FormResponse {
let mut location = None;
let mut body = String::new();
if response.status.is_redirection() {
location = response.header("location").map(String::from);
} else {
body = String::from_utf8_lossy(&response.body).into_owned();
}
FormResponse {
location,
status: response.status,
body,
}
}
/// The id of the record the redirect named: the rightmost path segment that reads as a
/// number, so `/calendar/events/42` and `/calendar/events/42/edit` both answer 42.
pub fn extract_id(&self) -> Result<i64, Error> {
let location = self
.location
.as_deref()
.filter(|location| !location.is_empty())
.ok_or_else(|| Error::api(0, "no location header in response"))?;
let path = match Url::parse(location) {
Ok(url) => url.path().to_string(),
Err(url::ParseError::RelativeUrlWithoutBase) => location
.split(['?', '#'])
.next()
.unwrap_or_default()
.to_string(),
Err(error) => {
return Err(Error::api(
0,
format!("failed to parse location URL: {error}"),
));
}
};
path.trim_end_matches('/')
.rsplit('/')
.find_map(|segment| segment.parse().ok())
.ok_or_else(|| Error::api(0, format!("no numeric ID found in location: {location}")))
}
}