Skip to main content

cufflink_types/host/
http.rs

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