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