Skip to main content

hey_sdk/services/
attachments.rs

1//! Uploading an outgoing attachment, which takes two requests: HEY reserves an Active
2//! Storage blob and names a storage URL, and the bytes then go to that URL rather than to
3//! HEY.
4
5use base64::Engine;
6use base64::engine::general_purpose::STANDARD;
7use bytes::Bytes;
8use url::Url;
9
10use crate::client::{Client, MAX_RESPONSE_BODY_BYTES, read_body};
11use crate::error::Error;
12use crate::generated::types::{
13    CreateDirectUploadRequestContent, DirectUpload, DirectUploadBlob, DirectUploadTarget,
14};
15use crate::http::header::AUTHORIZATION;
16use crate::http::{HeaderMap, HeaderName, HeaderValue, Method, Request};
17use crate::security::require_secure_endpoint;
18
19pub use crate::generated::services::attachments::*;
20
21/// What an attachment is taken to be when the caller names no content type.
22const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
23
24impl Attachments<'_> {
25    /// Reserves an Active Storage blob and uploads the bytes to the storage URL HEY named.
26    /// The answer's `attachable_sgid` is what embeds the attachment in Trix rich text.
27    ///
28    /// Empty content is an empty attachment rather than a mistake, so only a missing
29    /// filename is refused.
30    pub async fn upload(
31        &self,
32        filename: &str,
33        content_type: Option<&str>,
34        content: impl Into<Bytes>,
35    ) -> Result<DirectUpload, Error> {
36        if filename.is_empty() {
37            return Err(Error::usage("an attachment needs a filename"));
38        }
39
40        let content = content.into();
41        #[allow(clippy::cast_possible_wrap)]
42        // An allocation never exceeds isize::MAX bytes, so the length fits.
43        let body = CreateDirectUploadRequestContent {
44            blob: DirectUploadBlob {
45                filename: filename.to_string(),
46                byte_size: content.len() as i64,
47                checksum: STANDARD.encode(md5::compute(&content).0),
48                content_type: content_type.unwrap_or(DEFAULT_CONTENT_TYPE).to_string(),
49            },
50        };
51        // Two requests, one operation: one limit over the reservation and the bytes.
52        self.client()
53            .within_limit(Box::pin(async move {
54                let upload = reserved(self.create_direct_upload(&body).await?)?;
55                store(self.client(), &upload.direct_upload, content).await?;
56                Ok(upload)
57            }))
58            .await
59    }
60}
61
62/// The blob HEY reserved, once it carries everything the upload needs. HEY answers 200 with
63/// a payload rather than a status when it has nothing to give, so the fields are what say
64/// whether there is an upload to make.
65fn reserved(upload: DirectUpload) -> Result<DirectUpload, Error> {
66    if upload.signed_id.is_empty()
67        || upload.attachable_sgid.is_empty()
68        || upload.direct_upload.url.is_empty()
69    {
70        Err(Error::api(
71            0,
72            "HEY returned an empty attachment upload response",
73        ))
74    } else {
75        Ok(upload)
76    }
77}
78
79/// Puts the bytes to the storage service.
80///
81/// This is the one request the SDK makes outside the HEY API. The storage URL
82/// authenticates itself and takes exactly the headers HEY named — including any
83/// `Authorization` the storage service wants, which is why the HEY credentials must not
84/// ride along. Going through [`crate::Client::execute`] would attach them, so the request
85/// is built here and sent on the client's own [`crate::http::HttpClient`], which carries
86/// the connection pool, the timeout and whatever else the caller configured.
87///
88/// A storage service answers a failure with a document of its own, and that is all this
89/// reads: the answer is held to [`MAX_RESPONSE_BODY_BYTES`] so a service saying too much
90/// cannot be what runs the caller out of memory.
91async fn store(client: &Client, target: &DirectUploadTarget, content: Bytes) -> Result<(), Error> {
92    let url = Url::parse(&target.url)?;
93    require_secure_endpoint(&url)
94        .map_err(|error| Error::usage(format!("unsafe attachment upload target: {error}")))?;
95
96    let path = url.path().to_string();
97    let mut request = Request::builder()
98        .method(Method::PUT)
99        .uri(url.as_str())
100        .body(content)
101        .map_err(Error::from_std)?;
102    *request.headers_mut() = storage_headers(target)?;
103    // The storage service's answer is held to the operation's deadline like HEY's own.
104    let (status, headers, body) = client
105        .within_deadline(client.deadline(), async {
106            let answered = client.http().send(request).await?;
107            let status = answered.status();
108            let headers = answered.headers().clone();
109            let body = read_body(
110                answered.into_body(),
111                MAX_RESPONSE_BODY_BYTES,
112                &Method::PUT,
113                &path,
114            )
115            .await?;
116            Ok((status, headers, body))
117        })
118        .await?;
119    if status.is_success() {
120        Ok(())
121    } else {
122        Err(Error::from_response(status, &Method::PUT, &headers, &body))
123    }
124}
125
126/// The headers HEY named, with any `Authorization` among them dropped: the SDK's own
127/// credentials never reach the storage service, and neither does a stale one HEY echoed.
128fn storage_headers(target: &DirectUploadTarget) -> Result<HeaderMap, Error> {
129    let mut headers = HeaderMap::new();
130    for (name, value) in target.headers.iter().flatten() {
131        let name = HeaderName::from_bytes(name.as_bytes())
132            .map_err(|_| Error::api(0, format!("{name:?} is not a valid header name")))?;
133        let value = HeaderValue::from_str(value)
134            .map_err(|_| Error::api(0, format!("{name} carries an unsendable value")))?;
135        headers.insert(name, value);
136    }
137    headers.remove(AUTHORIZATION);
138    Ok(headers)
139}