Skip to main content

cufflink_types/host/
http.rs

1//! `http_fetch_v2`.
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(deny_unknown_fields)]
7pub struct FetchRequestV2 {
8    pub method: String,
9    pub url: String,
10    #[serde(default, skip_serializing_if = "Vec::is_empty")]
11    pub headers: Vec<(String, String)>,
12    #[serde(
13        default,
14        rename = "body_b64",
15        with = "crate::serde_util::base64_bytes::option",
16        skip_serializing_if = "Option::is_none"
17    )]
18    pub body: Option<Vec<u8>>,
19    /// Assembled into a `multipart/form-data` body by the host; exclusive
20    /// with `body`.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub multipart: Option<Vec<MultipartPart>>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub timeout_ms: Option<u32>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub max_response_bytes: Option<u64>,
27    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
28    pub follow_redirects: bool,
29}
30
31impl FetchRequestV2 {
32    pub const METHODS: [&'static str; 7] =
33        ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct MultipartPart {
39    pub name: String,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub filename: Option<String>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub content_type: Option<String>,
44    #[serde(rename = "body_b64", with = "crate::serde_util::base64_bytes")]
45    pub body: Vec<u8>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(tag = "result", rename_all = "snake_case")]
50pub enum FetchResponseV2 {
51    /// Any response the server gave, whatever its status.
52    Ok {
53        status: u16,
54        /// In the order received, repeated names kept.
55        headers: Vec<(String, String)>,
56        #[serde(rename = "body_b64", with = "crate::serde_util::base64_bytes")]
57        body: Vec<u8>,
58    },
59    Error(HttpErrorEnvelope),
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct HttpErrorEnvelope {
65    pub kind: HttpErrorKind,
66    pub message: String,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum HttpErrorKind {
72    Denied,
73    InvalidRequest,
74    Dns,
75    Connect,
76    Tls,
77    Timeout,
78    TooLarge,
79    Redirect,
80    Io,
81}
82
83impl HttpErrorKind {
84    pub fn as_str(self) -> &'static str {
85        match self {
86            HttpErrorKind::Denied => "denied",
87            HttpErrorKind::InvalidRequest => "invalid_request",
88            HttpErrorKind::Dns => "dns",
89            HttpErrorKind::Connect => "connect",
90            HttpErrorKind::Tls => "tls",
91            HttpErrorKind::Timeout => "timeout",
92            HttpErrorKind::TooLarge => "too_large",
93            HttpErrorKind::Redirect => "redirect",
94            HttpErrorKind::Io => "io",
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::host::tests::{refused, round_trip};
103    use serde_json::json;
104
105    #[test]
106    fn a_minimal_request_carries_only_what_it_sets() {
107        round_trip(
108            &FetchRequestV2 {
109                method: "GET".into(),
110                url: "https://api.example.com/v1?expand[]=a&expand[]=b".into(),
111                ..Default::default()
112            },
113            json!({"method": "GET", "url": "https://api.example.com/v1?expand[]=a&expand[]=b"}),
114        );
115    }
116
117    #[test]
118    fn bodies_and_parts_travel_as_base64() {
119        round_trip(
120            &FetchRequestV2 {
121                method: "POST".into(),
122                url: "https://a.example/".into(),
123                headers: vec![("accept".into(), "application/pdf".into())],
124                body: Some(vec![0, 255]),
125                timeout_ms: Some(1000),
126                max_response_bytes: Some(1024),
127                follow_redirects: true,
128                ..Default::default()
129            },
130            json!({
131                "method": "POST",
132                "url": "https://a.example/",
133                "headers": [["accept", "application/pdf"]],
134                "body_b64": "AP8=",
135                "timeout_ms": 1000,
136                "max_response_bytes": 1024,
137                "follow_redirects": true
138            }),
139        );
140        round_trip(
141            &FetchRequestV2 {
142                method: "POST".into(),
143                url: "https://gotenberg/forms/chromium/convert/html".into(),
144                multipart: Some(vec![
145                    MultipartPart {
146                        name: "files".into(),
147                        filename: Some("index.html".into()),
148                        content_type: Some("text/html".into()),
149                        body: b"<p>".to_vec(),
150                    },
151                    MultipartPart {
152                        name: "landscape".into(),
153                        body: b"true".to_vec(),
154                        ..Default::default()
155                    },
156                ]),
157                ..Default::default()
158            },
159            json!({
160                "method": "POST",
161                "url": "https://gotenberg/forms/chromium/convert/html",
162                "multipart": [
163                    {"name": "files", "filename": "index.html", "content_type": "text/html", "body_b64": "PHA+"},
164                    {"name": "landscape", "body_b64": "dHJ1ZQ=="}
165                ]
166            }),
167        );
168    }
169
170    #[test]
171    fn responses_are_tagged_by_result() {
172        round_trip(
173            &FetchResponseV2::Ok {
174                status: 404,
175                headers: vec![
176                    ("set-cookie".into(), "a=1".into()),
177                    ("set-cookie".into(), "b=2".into()),
178                ],
179                body: b"no".to_vec(),
180            },
181            json!({
182                "result": "ok",
183                "status": 404,
184                "headers": [["set-cookie", "a=1"], ["set-cookie", "b=2"]],
185                "body_b64": "bm8="
186            }),
187        );
188        for kind in [
189            HttpErrorKind::Denied,
190            HttpErrorKind::InvalidRequest,
191            HttpErrorKind::Dns,
192            HttpErrorKind::Connect,
193            HttpErrorKind::Tls,
194            HttpErrorKind::Timeout,
195            HttpErrorKind::TooLarge,
196            HttpErrorKind::Redirect,
197            HttpErrorKind::Io,
198        ] {
199            round_trip(
200                &FetchResponseV2::Error(HttpErrorEnvelope {
201                    kind,
202                    message: "why".into(),
203                }),
204                json!({"result": "error", "kind": kind.as_str(), "message": "why"}),
205            );
206        }
207    }
208
209    #[test]
210    fn unknown_tags_kinds_and_bodies_are_refused() {
211        refused::<FetchResponseV2>(json!({"result": "redirected"}));
212        refused::<FetchResponseV2>(json!({"result": "error", "kind": "proxy", "message": ""}));
213        refused::<FetchResponseV2>(json!({"status": 200, "headers": [], "body_b64": ""}));
214        refused::<FetchRequestV2>(json!({"method": "GET", "url": "u", "body_b64": "%%"}));
215        refused::<FetchRequestV2>(json!({"method": "GET", "url": "u", "retries": 2}));
216        refused::<FetchRequestV2>(json!({"method": "GET"}));
217    }
218}