1use 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 #[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 Ok {
56 status: u16,
57 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}