1use std::collections::BTreeMap;
23
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26
27use crate::ToolError;
28
29pub const ABI_OUT_OF_BOUNDS: i32 = -1;
31pub const ABI_NOTHING_HELD: i32 = -2;
33pub const ABI_LENGTH_MISMATCH: i32 = -3;
35
36pub const INLINE_MAX_BYTES: usize = 256 * 1024;
38
39pub const MAX_REDIRECTS: usize = 10;
41
42pub const REQUEST_MAX_BYTES: usize = 8 * 1024 * 1024;
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
49#[serde(deny_unknown_fields)]
50pub struct Request {
51 #[serde(default)]
52 pub method: Method,
53 #[schemars(
54 description = "An https URL; the host must be one the function's network setting allows."
55 )]
56 pub url: String,
57 #[serde(default)]
61 #[schemars(
62 description = "Request headers by name. A header a configured credential sets for the \
63 URL's origin is refused; host, content-length and other connection \
64 headers are ignored."
65 )]
66 pub headers: BTreeMap<String, String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub body: Option<RequestBody>,
69 #[serde(default)]
70 pub output: ResponseOutput,
71 #[serde(default = "Request::default_follow_redirects")]
76 #[schemars(description = format!(
77 "Follow up to {MAX_REDIRECTS} redirects, each checked like the first request. Stored \
78 credentials are attached per hop for that hop's origin only. On a redirect to \
79 another origin, authorization, cookie and proxy-authorization headers you set are \
80 dropped; other headers are sent as given."
81 ))]
82 pub follow_redirects: bool,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
86 #[schemars(
87 range(min = 1),
88 description = "Bound on the whole request; the call's own time limit applies regardless."
89 )]
90 pub timeout_ms: Option<u64>,
91}
92
93impl Request {
94 pub fn get(url: impl Into<String>) -> Self {
96 Request {
97 method: Method::Get,
98 url: url.into(),
99 headers: BTreeMap::new(),
100 body: None,
101 output: ResponseOutput::Inline,
102 follow_redirects: Self::default_follow_redirects(),
103 timeout_ms: None,
104 }
105 }
106
107 fn default_follow_redirects() -> bool {
108 true
109 }
110}
111
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
116#[serde(rename_all = "UPPERCASE")]
117#[schemars(description = "HTTP method.")]
118pub enum Method {
119 #[default]
120 Get,
121 Head,
122 Post,
123 Put,
124 Patch,
125 Delete,
126 Options,
127}
128
129impl Method {
130 pub fn as_str(&self) -> &'static str {
131 match self {
132 Method::Get => "GET",
133 Method::Head => "HEAD",
134 Method::Post => "POST",
135 Method::Put => "PUT",
136 Method::Patch => "PATCH",
137 Method::Delete => "DELETE",
138 Method::Options => "OPTIONS",
139 }
140 }
141}
142
143impl std::fmt::Display for Method {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.write_str(self.as_str())
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
150#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
151pub enum RequestBody {
152 #[schemars(description = "Send text, as text/plain unless a content-type header is given.")]
155 Text { text: String },
156 #[schemars(
158 description = "Send this value as JSON, as application/json unless a content-type \
159 header is given."
160 )]
161 Json { json: serde_json::Value },
162 #[schemars(description = "Stream a volume file as the body.")]
164 File {
165 #[schemars(
166 description = "Absolute volume path under /volume/tmp, or /volume/data when the agent enables it."
167 )]
168 path: String,
169 },
170}
171
172#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
174#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
175pub enum ResponseOutput {
176 #[default]
178 #[schemars(description = format!(
179 "Return the body as text in the reply (the default), up to {} KiB of UTF-8.",
180 INLINE_MAX_BYTES / 1024
181 ))]
182 Inline,
183 #[schemars(
186 description = "Save the body to a volume file, bounded only by the call's write \
187 budget, and reply with its size. Use this for large or binary responses."
188 )]
189 File {
190 #[schemars(description = "Absolute volume path, overwritten, parents created.")]
191 path: String,
192 },
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct Response {
198 pub status: u16,
199 #[schemars(description = "Lowercase names; repeated headers joined with a comma.")]
201 pub headers: BTreeMap<String, String>,
202 pub body: ResponseBody,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
206#[serde(tag = "type", rename_all = "snake_case")]
207pub enum ResponseBody {
208 Inline { text: String },
209 File { path: String, bytes: u64 },
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct HttpError {
215 pub kind: HttpErrorKind,
216 pub message: String,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum HttpErrorKind {
222 InvalidRequest,
225 HostNotAllowed,
227 ForbiddenDestination,
230 CredentialHeader,
232 CredentialUnavailable,
235 TooManyRedirects,
236 Timeout,
237 TooLarge,
239 NotText,
241 WriteBudget,
243 BadPath,
245 Connection,
247 Abi,
249}
250
251impl std::fmt::Display for HttpError {
252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253 f.write_str(&self.message)
254 }
255}
256
257impl std::error::Error for HttpError {}
258
259impl From<HttpError> for ToolError {
260 fn from(error: HttpError) -> Self {
261 ToolError::new(error.message)
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267#[serde(rename_all = "snake_case")]
268pub enum Answer {
269 Ok(Response),
270 Error(HttpError),
271}
272
273impl From<Answer> for Result<Response, HttpError> {
274 fn from(answer: Answer) -> Self {
275 match answer {
276 Answer::Ok(response) => Ok(response),
277 Answer::Error(error) => Err(error),
278 }
279 }
280}
281
282pub fn request(request: &Request) -> Result<Response, HttpError> {
284 let abi = |message: String| HttpError {
285 kind: HttpErrorKind::Abi,
286 message,
287 };
288 let body = serde_json::to_vec(request).map_err(|e| abi(format!("request is not JSON: {e}")))?;
290 let answer = imports::call(&body).map_err(abi)?;
292 match serde_json::from_slice::<Answer>(&answer) {
294 Ok(answer) => answer.into(),
295 Err(e) => Err(abi(format!("subako_http answered with no answer: {e}"))),
296 }
297}
298
299#[cfg(target_family = "wasm")]
300mod imports {
301 #[link(wasm_import_module = "subako_http")]
302 unsafe extern "C" {
303 fn request(ptr: *const u8, len: usize) -> i32;
304 fn read_response(ptr: *mut u8, len: usize) -> i32;
305 }
306
307 pub(super) fn call(body: &[u8]) -> Result<Vec<u8>, String> {
309 let failed = |code: i32| format!("subako_http returned {code}");
310 let len = unsafe { request(body.as_ptr(), body.len()) };
312 let len = usize::try_from(len).map_err(|_| failed(len))?;
313 let mut answer = vec![0u8; len];
314 match unsafe { read_response(answer.as_mut_ptr(), len) } {
316 0 => Ok(answer),
317 code => Err(failed(code)),
318 }
319 }
320}
321
322#[cfg(not(target_family = "wasm"))]
323mod imports {
324 pub(super) fn call(_: &[u8]) -> Result<Vec<u8>, String> {
325 Err("subako_http exists only inside the function worker".to_string())
326 }
327}