Skip to main content

hey_sdk/
raw.rs

1//! The verbs for the parts of HEY the model does not describe. They take a path rather
2//! than a route and hand back the answer undecoded, but everything else is what a modelled
3//! call gets: the credentials, the account scope, the hooks, the retries and the resend
4//! after a refreshed 401.
5//!
6//! A path here goes out as it was written — no `.json` suffix is added — since a caller
7//! reaching past the model is naming a path HEY serves, not one Smithy had to spell
8//! around.
9
10use bytes::Bytes;
11use serde::Serialize;
12use tokio::io::{AsyncWrite, AsyncWriteExt};
13use url::Url;
14
15use crate::client::{Client, Response};
16use crate::error::Error;
17use crate::form::FormResponse;
18use crate::http::{HeaderMap, Method};
19use crate::operation::Operation;
20use crate::security::is_same_origin;
21
22impl Client {
23    /// Reads a path the model does not cover.
24    pub async fn get(&self, path: &str) -> Result<Response, Error> {
25        self.execute(self.raw(Method::GET, path)?).await
26    }
27
28    /// Reads the HTML representation, for the pages HEY serves no JSON for.
29    pub async fn get_html(&self, path: &str) -> Result<Response, Error> {
30        let mut operation = self.raw(Method::GET, path)?;
31        operation.accept("text/html");
32        self.execute(operation).await
33    }
34
35    /// Reads an export, which HEY streams as a file rather than a document.
36    pub async fn get_csv(&self, path: &str) -> Result<Response, Error> {
37        self.execute(self.csv(path)?).await
38    }
39
40    /// A request for one of the exports HEY streams as a file. This and [`Client::get_csv`]
41    /// stand to each other as [`Client::form`] does to [`Client::post_form`]: a call that
42    /// has something to say about itself builds the operation here and gives it an
43    /// [`Operation::info`] before sending it.
44    pub fn csv(&self, path: &str) -> Result<Operation, Error> {
45        let mut operation = self.raw(Method::GET, path)?;
46        operation.accept("text/csv");
47        Ok(operation)
48    }
49
50    /// Reads a file whole. [`Client::download_blob`] writes one out as it arrives instead,
51    /// for a file too large to want in memory.
52    pub async fn get_blob(&self, path: &str) -> Result<Response, Error> {
53        self.execute(self.blob(path)?).await
54    }
55
56    /// Writes a file to `destination` as it arrives, and answers how many bytes went and
57    /// what headers came with them. Nothing is read into memory, and nothing is resent:
58    /// once bytes are on their way to the destination a second attempt would double them.
59    pub async fn download_blob(
60        &self,
61        path: &str,
62        destination: &mut (impl AsyncWrite + Unpin),
63    ) -> Result<(u64, HeaderMap), Error> {
64        let deadline = self.deadline();
65        let response = self.stream(self.blob(path)?, deadline).await?;
66        let headers = response.headers().clone();
67        let mut body = response.into_body();
68        // The hooks heard the operation end when the answer arrived; the operation's one
69        // deadline still holds over the bytes that follow it.
70        let written = self
71            .within_deadline(deadline, async {
72                let mut written = 0;
73                while let Some(chunk) = body.chunk().await? {
74                    destination
75                        .write_all(&chunk)
76                        .await
77                        .map_err(Error::from_std)?;
78                    written += chunk.len() as u64;
79                }
80                Ok(written)
81            })
82            .await?;
83        Ok((written, headers))
84    }
85
86    /// Posts a JSON body to a path the model does not cover.
87    pub async fn post(&self, path: &str, body: &impl Serialize) -> Result<Response, Error> {
88        self.send_json(Method::POST, path, body, "application/json")
89            .await
90    }
91
92    /// Posts to an endpoint that may answer with something other than JSON.
93    pub async fn post_mutation(
94        &self,
95        path: &str,
96        body: &impl Serialize,
97    ) -> Result<Response, Error> {
98        self.send_json(Method::POST, path, body, "*/*").await
99    }
100
101    /// Puts a JSON body to a path the model does not cover.
102    pub async fn put(&self, path: &str, body: &impl Serialize) -> Result<Response, Error> {
103        self.send_json(Method::PUT, path, body, "application/json")
104            .await
105    }
106
107    /// Patches a path the model does not cover with a JSON body.
108    pub async fn patch(&self, path: &str, body: &impl Serialize) -> Result<Response, Error> {
109        self.send_json(Method::PATCH, path, body, "application/json")
110            .await
111    }
112
113    /// Patches an endpoint that may answer with something other than JSON.
114    pub async fn patch_mutation(
115        &self,
116        path: &str,
117        body: &impl Serialize,
118    ) -> Result<Response, Error> {
119        self.send_json(Method::PATCH, path, body, "*/*").await
120    }
121
122    /// Deletes at a path the model does not cover.
123    pub async fn delete(&self, path: &str) -> Result<Response, Error> {
124        self.execute(self.raw(Method::DELETE, path)?).await
125    }
126
127    /// Posts a form the way a browser would, and captures the redirect HEY answers with
128    /// rather than following it. [`FormResponse::extract_id`] reads the created record's
129    /// id out of that redirect.
130    ///
131    /// This and the three below are [`Client::form`] and [`Client::send_form`] together. A
132    /// call that has something to say about itself builds the operation with those two
133    /// instead, and gives it an [`Operation::info`] on the way.
134    pub async fn post_form(
135        &self,
136        path: &str,
137        fields: &[(&str, &str)],
138    ) -> Result<FormResponse, Error> {
139        let mut operation = self.form(Method::POST, path)?;
140        operation.form(fields);
141        self.send_form(operation).await
142    }
143
144    /// Patches through the form endpoint, the way a browser's edit form would.
145    pub async fn patch_form(
146        &self,
147        path: &str,
148        fields: &[(&str, &str)],
149    ) -> Result<FormResponse, Error> {
150        let mut operation = self.form(Method::PATCH, path)?;
151        operation.form(fields);
152        self.send_form(operation).await
153    }
154
155    /// Deletes through the form endpoint, which answers a redirect. The request carries no
156    /// body, and so no content type either.
157    pub async fn delete_form(&self, path: &str) -> Result<FormResponse, Error> {
158        self.send_form(self.form(Method::DELETE, path)?).await
159    }
160
161    /// Posts a multipart body the caller assembled, for the endpoints that take a file.
162    pub async fn post_multipart(
163        &self,
164        path: &str,
165        content_type: String,
166        body: Bytes,
167    ) -> Result<FormResponse, Error> {
168        let mut operation = self.form(Method::POST, path)?;
169        operation.multipart(content_type, body);
170        self.send_form(operation).await
171    }
172
173    /// An operation for a path the model does not cover. An absolute URL is sent as it
174    /// stands, provided the credentials may travel to it; anything else is resolved
175    /// against the base URL.
176    pub(crate) fn raw(&self, method: Method, path: &str) -> Result<Operation, Error> {
177        let mut operation = match self.absolute(path)? {
178            Some(url) => Operation::at(method, url),
179            None => Operation::raw(method, path.to_string()),
180        };
181        operation.without_json_suffix();
182        Ok(operation)
183    }
184
185    /// The URL a path names when it already is one. HTTPS goes anywhere; plain HTTP only
186    /// back to the base URL's own host, which is how a HEY running on this machine is
187    /// reached, and nowhere else — the credentials would go with it.
188    fn absolute(&self, path: &str) -> Result<Option<Url>, Error> {
189        if path.starts_with("https://") || path.starts_with("http://") {
190            let url = Url::parse(path)?;
191            if url.scheme() == "https" || is_same_origin(&url, self.base_url()) {
192                Ok(Some(url))
193            } else {
194                Err(Error::usage(format!("URL must use HTTPS, got: {path}")))
195            }
196        } else {
197            Ok(None)
198        }
199    }
200
201    /// A blob is read from the HEY origin and nowhere else: the request carries the
202    /// credentials, and only a redirect — which the client strips them before following —
203    /// may lead off it. The response cache is for JSON documents, so a blob goes past it.
204    fn blob(&self, path: &str) -> Result<Operation, Error> {
205        let mut operation = self.raw(Method::GET, path)?;
206        if let Some(url) = &operation.url
207            && !is_same_origin(url, self.base_url())
208        {
209            return Err(Error::usage("a blob URL must start on the HEY origin"));
210        }
211        operation.accept("*/*").no_cache();
212        Ok(operation)
213    }
214
215    async fn send_json(
216        &self,
217        method: Method,
218        path: &str,
219        body: &impl Serialize,
220        accept: &'static str,
221    ) -> Result<Response, Error> {
222        let mut operation = self.raw(method, path)?;
223        operation.json(body)?.accept(accept);
224        self.execute(operation).await
225    }
226}