Skip to main content

subako_function_sdk/
http.rs

1//! HTTP from a guest. The worker makes the request on the guest's behalf,
2//! under the invocation's egress policy: which hosts it may reach and which
3//! credential headers ride along to which origins. A file body or a file
4//! response moves between the socket and the volume without passing through
5//! guest memory.
6//!
7//! # ABI
8//!
9//! The import module `subako_http` holds two functions:
10//!
11//! - `request(ptr: i32, len: i32) -> i32` reads `len` bytes at `ptr` as a
12//!   [`Request`] in JSON, performs it, and holds the answer. It returns the
13//!   answer's length in bytes.
14//! - `read_response(ptr: i32, len: i32) -> i32` copies the held answer to
15//!   `ptr`, where `len` must be the length `request` returned, and forgets
16//!   it. It returns 0.
17//!
18//! The answer is [`Answer`] in JSON: `{"ok": Response}` or
19//! `{"error": HttpError}`. A negative return is a misuse of the ABI itself:
20//! [`ABI_OUT_OF_BOUNDS`], [`ABI_NOTHING_HELD`], or [`ABI_LENGTH_MISMATCH`].
21
22use std::collections::BTreeMap;
23
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26
27use crate::ToolError;
28
29/// The guest's memory range lies outside its memory.
30pub const ABI_OUT_OF_BOUNDS: i32 = -1;
31/// `read_response` ran with no answer held.
32pub const ABI_NOTHING_HELD: i32 = -2;
33/// `read_response`'s length is not the answer's.
34pub const ABI_LENGTH_MISMATCH: i32 = -3;
35
36/// The largest response body returned inline, in bytes.
37pub const INLINE_MAX_BYTES: usize = 256 * 1024;
38
39/// The most redirects one request follows.
40pub const MAX_REDIRECTS: usize = 10;
41
42/// The largest request JSON the worker reads from guest memory, in bytes. A
43/// larger body travels as a file.
44pub const REQUEST_MAX_BYTES: usize = 8 * 1024 * 1024;
45
46/// One HTTP request. The `schemars` descriptions are what a model reads when
47/// the `http` package serves this as its input.
48#[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    /// Sent as given on the first hop. Connection-level headers such as
58    /// `host` and `content-length` are the worker's own and are dropped, and
59    /// a header a credential sets for the hop's origin is refused.
60    #[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    /// Up to [`MAX_REDIRECTS`] hops, each checked like the first. A stored
72    /// credential rides only to its own origin; on a hop to another origin the
73    /// guest's own authorization, cookie, and proxy-authorization headers are
74    /// dropped and the rest are sent as given.
75    #[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    /// Bounds the whole request, every hop and the body included. The
84    /// invocation's own deadline bounds it regardless.
85    #[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    /// A GET with every other field at its default.
95    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/// The methods a request may use. CONNECT, TRACE, and TRACK have no variant:
113/// the first asks for a tunnel, and the others echo the request, credential
114/// headers included, back to the guest.
115#[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    /// Sent as UTF-8, `text/plain; charset=utf-8` unless a header says
153    /// otherwise.
154    #[schemars(description = "Send text, as text/plain unless a content-type header is given.")]
155    Text { text: String },
156    /// Sent serialized, `application/json` unless a header says otherwise.
157    #[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    /// The volume file at `path`, streamed.
163    #[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/// Where the response body goes.
173#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
174#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
175pub enum ResponseOutput {
176    /// Into the answer as text, up to [`INLINE_MAX_BYTES`].
177    #[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    /// Into the volume file at `path`, overwritten, parents created, counted
184    /// against the invocation's write budget.
185    #[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/// The final hop's response.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct Response {
198    pub status: u16,
199    /// Lowercase names; repeated headers joined with `, `.
200    #[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/// Why a request produced no response.
213#[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    /// The request does not parse, or names a method, URL, or header no
223    /// request can carry.
224    InvalidRequest,
225    /// The function's network policy does not admit the host.
226    HostNotAllowed,
227    /// The worker refuses the destination: plain http, or an address in a
228    /// private or reserved range.
229    ForbiddenDestination,
230    /// A header the request sets is one a credential sets for that origin.
231    CredentialHeader,
232    /// The session's vaults hold no single usable credential for an origin
233    /// the function's grant lists.
234    CredentialUnavailable,
235    TooManyRedirects,
236    Timeout,
237    /// The response body is over [`INLINE_MAX_BYTES`].
238    TooLarge,
239    /// The response body is not UTF-8, so it cannot be returned inline.
240    NotText,
241    /// Saving the response would pass the invocation's write budget.
242    WriteBudget,
243    /// A body or output path is not a usable volume file.
244    BadPath,
245    /// The connection failed or the server broke the exchange.
246    Connection,
247    /// The import itself misbehaved, or the program runs outside the worker.
248    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/// What `request` holds for `read_response`.
266#[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
282/// Performs `request` through the worker.
283pub fn request(request: &Request) -> Result<Response, HttpError> {
284    let abi = |message: String| HttpError {
285        kind: HttpErrorKind::Abi,
286        message,
287    };
288    // COVERAGE(never): a request is plain data, so it always serializes.
289    let body = serde_json::to_vec(request).map_err(|e| abi(format!("request is not JSON: {e}")))?;
290    // COVERAGE(untestable): off the worker the import always fails, so only a guest under wasmtime gets past it.
291    let answer = imports::call(&body).map_err(abi)?;
292    // COVERAGE(untestable): guest code runs under wasmtime, where the host's llvm-cov cannot see it.
293    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    // COVERAGE(untestable): guest code runs under wasmtime, where the host's llvm-cov cannot see it.
308    pub(super) fn call(body: &[u8]) -> Result<Vec<u8>, String> {
309        let failed = |code: i32| format!("subako_http returned {code}");
310        // SAFETY: the host reads `body.len()` bytes from `body`'s start.
311        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        // SAFETY: the host writes exactly `len` bytes into `answer`.
315        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}