Skip to main content

submilli_engine/stdlib/http/
declaration.rs

1//! The `submilli:http` package declaration — the LLM-facing type surface
2//! (`Response`, `DownloadOptions`, `DownloadResult`, `Headers`, verb helpers).
3
4use std::collections::BTreeMap;
5
6use crate::{
7    DefaultValue, Dispatch, MethodSig, PackageDeclaration, Param, PropertySig, Span, Type,
8    TypeKind, TypeSymbol, ValueKind, ValueSymbol,
9};
10
11use super::MODULE_NAME;
12
13/// The `Response` method that parses the body as JSON and checks it against
14/// its type argument.
15const RESPONSE_JSON: &str = "json";
16
17/// The mangled name of `Response#json`, the key the typechecker's gate, the
18/// runtime-generic set and the host registration share.
19pub(crate) fn response_json() -> crate::MangledName {
20    crate::mangle::extend(
21        &crate::mangle::package_symbol(MODULE_NAME, "Response"),
22        RESPONSE_JSON,
23    )
24}
25
26/// Whether `mangled` is `Response#json`.
27pub(crate) fn is_checked_json(mangled: &crate::MangledName) -> bool {
28    *mangled == response_json()
29}
30
31pub fn package_declaration() -> PackageDeclaration {
32    let mut defs = PackageDeclaration::with_package(MODULE_NAME);
33
34    defs.types.insert(
35        "Headers".to_string(),
36        TypeSymbol {
37            name: "Headers".to_string(),
38            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Headers"),
39            declaration_span: Span::at(crate::FileId::HTTP),
40            kind: TypeKind::Alias {
41                generics: Default::default(),
42                ty: Type::InterfaceRef { mangled: crate::mangle::prelude("Map"),
43                    package: crate::Package::prelude(),
44                    name: "Map".to_string(),
45                    args: vec![Type::String, Type::String],
46                },
47                doc: crate::doc(crate::FileId::HTTP,
48                    "/** HTTP header bag: `Map<string, string>` with names lowercased on the wire. Future slice may add case-insensitive lookup. */",
49                ),
50            },
51        },
52    );
53
54    insert_response_interface(&mut defs);
55    defs.runtime_generics.insert(response_json());
56    insert_download_options_interface(&mut defs);
57    insert_download_result_interface(&mut defs);
58
59    // body-less: prevents get(url, h) from ambiguating body vs headers
60    for verb in &["get", "delete", "head", "options"] {
61        insert_verb_fn(&mut defs, verb, /* has_body = */ false);
62    }
63    for verb in &["post", "put", "patch"] {
64        insert_verb_fn(&mut defs, verb, /* has_body = */ true);
65    }
66
67    defs.values.insert(
68        "download".to_string(),
69        ValueSymbol {
70            name: "download".to_string(),
71            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "download"),
72            declaration_span: Span::at(crate::FileId::HTTP),
73            kind: ValueKind::Function {
74                generics: Default::default(),
75                params: vec![
76                    Param::new("url", Type::String),
77                    Param::new("path", Type::String),
78                    Param::with_default(
79                        "options",
80                        optional_download_options_type(),
81                        DefaultValue::Undefined,
82                    ),
83                ],
84                ret: download_result_type(),
85                type_predicate: None,
86                doc: crate::doc(crate::FileId::HTTP,
87                    "/**\n * Download a remote file directly to the VFS — wget-style. Streams the response body to disk so memory stays bounded regardless of file size. Atomic: a crash mid-download leaves a `.tmp` sibling rather than a half-written final file. Traps on transport failure (DNS, connect, TLS, timeout), oversize response, refuse-on-exists, or VFS path escape.\n * @param url Absolute URL. A path with a `.` or `..` segment, in any spelling such as `%2E%2E`, throws `TypeError` and sends nothing.\n * @param path Destination VFS path (e.g. `\"/workspace/dataset.csv\"`). The parent directory must exist — call `fs.mkdir` first if needed.\n * @param options Optional `DownloadOptions` bag: `overwrite` (default `false`), `maxBytes` (default and upper bound: tier-configured), `headers`, `timeout` (default and upper bound: 60_000 ms, or a lower operator limit), `decompress` (default `false`). Omit or pass `undefined` to take every default.\n * @capability http.download { host: $url.host, url_path: $url.path, vfs_path: $path, max_bytes: number, overwrite: boolean, decompress: boolean }\n * @capability fs.write { path, max_bytes: number }\n */",
88                ),
89            },
90        },
91    );
92
93    defs.values.insert(
94        "request".to_string(),
95        ValueSymbol {
96            name: "request".to_string(),
97            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "request"),
98            declaration_span: Span::at(crate::FileId::HTTP),
99            kind: ValueKind::Function {
100                generics: Default::default(),
101                params: vec![
102                    Param::new("method", Type::String),
103                    Param::new("url", Type::String),
104                    Param::with_default(
105                        "body",
106                        optional_body_type(),
107                        DefaultValue::Undefined,
108                    ),
109                    Param::with_default(
110                        "headers",
111                        optional_headers_type(),
112                        DefaultValue::Undefined,
113                    ),
114                ],
115                ret: response_type(),
116                type_predicate: None,
117                doc: crate::doc(crate::FileId::HTTP,
118                    "/**\n * Issue an HTTP request with a runtime-chosen verb. Returns the full response (status, headers, UTF-8-decoded body). Traps on transport failure (DNS, connect, TLS, timeout) — surfaced as a guest `Error`.\n * @param method HTTP method (case-insensitive). Common verbs: `\"GET\"` / `\"POST\"` / `\"PUT\"` / `\"PATCH\"` / `\"DELETE\"` / `\"HEAD\"` / `\"OPTIONS\"`.\n * @param url Absolute URL. A path with a `.` or `..` segment, in any spelling such as `%2E%2E`, throws `TypeError` and sends nothing.\n * @param body Optional request body. `string` is UTF-8 encoded and auto-tagged `Content-Type: text/plain; charset=utf-8` unless the caller overrides; a structural object or array is JSON-encoded and auto-tagged `Content-Type: application/json` unless the caller overrides; `Uint8Array` sends the bytes verbatim with no defaulted Content-Type; `null` sends the JSON payload `null` with default `Content-Type: application/json`; `undefined` (or omitted) sends an empty body.\n * @param headers Optional request headers. Omit or pass `undefined` to send no extra headers.\n * @capability http.request { method: $method, host: $url.host, path: $url.path, body_size: number, timeout_ms: number }\n */",
119                ),
120            },
121        },
122    );
123
124    defs
125}
126
127fn insert_verb_fn(defs: &mut PackageDeclaration, verb: &str, has_body: bool) {
128    let upper = verb.to_uppercase();
129    let doc = if has_body {
130        format!(
131            "/**\n * Issue a synchronous HTTP {upper}. Returns the full response (status, headers, UTF-8-decoded body). Traps on transport failure (DNS, connect, TLS, timeout) — surfaced as a guest `Error`.\n * @param url Absolute URL. A path with a `.` or `..` segment, in any spelling such as `%2E%2E`, throws `TypeError` and sends nothing.\n * @param body Optional request body. `string` is UTF-8 encoded and auto-tagged `Content-Type: text/plain; charset=utf-8` unless the caller overrides; a structural object or array is JSON-encoded and auto-tagged `Content-Type: application/json` unless the caller overrides; `Uint8Array` sends the bytes verbatim with no defaulted Content-Type; `null` sends the JSON payload `null` with default `Content-Type: application/json`; `undefined` (or omitted) sends an empty body.\n * @param headers Optional request headers. Omit or pass `undefined` to send no extra headers.\n */",
132        )
133    } else {
134        format!(
135            "/**\n * Issue a synchronous HTTP {upper}. Returns the full response (status, headers, UTF-8-decoded body). Traps on transport failure (DNS, connect, TLS, timeout) — surfaced as a guest `Error`.\n * @param url Absolute URL. A path with a `.` or `..` segment, in any spelling such as `%2E%2E`, throws `TypeError` and sends nothing.\n * @param headers Optional request headers. Omit or pass `undefined` to send no extra headers.\n */",
136        )
137    };
138    let capability = format!(
139        " * @capability http.{verb} {{ host: $url.host, path: $url.path, body_size: number, timeout_ms: number }}\n */"
140    );
141    let doc = doc.replace(" */", &capability);
142    let mut params = vec![Param::new("url", Type::String)];
143    if has_body {
144        params.push(Param::with_default(
145            "body",
146            optional_body_type(),
147            DefaultValue::Undefined,
148        ));
149    }
150    params.push(Param::with_default(
151        "headers",
152        optional_headers_type(),
153        DefaultValue::Undefined,
154    ));
155    defs.values.insert(
156        verb.to_string(),
157        ValueSymbol {
158            name: verb.to_string(),
159            mangled_name: crate::mangle::package_symbol(MODULE_NAME, verb),
160            declaration_span: Span::at(crate::FileId::HTTP),
161            kind: ValueKind::Function {
162                generics: Default::default(),
163                params,
164                ret: response_type(),
165                type_predicate: None,
166                doc: crate::doc(crate::FileId::HTTP, &doc),
167            },
168        },
169    );
170}
171
172fn optional_headers_type() -> Type {
173    Type::Union(vec![headers_type(), Type::Undefined])
174}
175
176fn optional_body_type() -> Type {
177    Type::Union(vec![
178        Type::String,
179        Type::Uint8Array,
180        // JSON-encoded at runtime via the `$Object` `toJson` vtable slot.
181        Type::any_object(),
182        Type::Array(Box::new(Type::Unknown)),
183        Type::Null,
184        Type::Undefined,
185    ])
186}
187
188fn response_type() -> Type {
189    Type::InterfaceRef {
190        mangled: crate::mangle::package_symbol(MODULE_NAME, "Response"),
191        package: crate::Package(MODULE_NAME.to_string()),
192        name: "Response".to_string(),
193        args: Vec::new(),
194    }
195}
196
197fn download_result_type() -> Type {
198    Type::InterfaceRef {
199        mangled: crate::mangle::package_symbol(MODULE_NAME, "DownloadResult"),
200        package: crate::Package(MODULE_NAME.to_string()),
201        name: "DownloadResult".to_string(),
202        args: Vec::new(),
203    }
204}
205
206fn download_options_type() -> Type {
207    Type::InterfaceRef {
208        mangled: crate::mangle::package_symbol(MODULE_NAME, "DownloadOptions"),
209        package: crate::Package(MODULE_NAME.to_string()),
210        name: "DownloadOptions".to_string(),
211        args: Vec::new(),
212    }
213}
214
215fn optional_download_options_type() -> Type {
216    Type::Union(vec![download_options_type(), Type::Undefined])
217}
218
219fn headers_type() -> Type {
220    Type::Alias {
221        mangled: crate::mangle::package_symbol(MODULE_NAME, "Headers"),
222        package: crate::Package(MODULE_NAME.to_string()),
223        name: "Headers".to_string(),
224        args: Vec::new(),
225        ty: Box::new(Type::InterfaceRef {
226            mangled: crate::mangle::prelude("Map"),
227            package: crate::Package::prelude(),
228            name: "Map".to_string(),
229            args: vec![Type::String, Type::String],
230        }),
231    }
232}
233
234fn insert_response_interface(defs: &mut PackageDeclaration) {
235    let mut properties = BTreeMap::new();
236    insert_property(
237        &mut properties,
238        "body",
239        Type::String,
240        "/** Response body, decoded as UTF-8. For binary responses, a future `bytes()` method will return `Uint8Array` without lossy decoding. */",
241    );
242    insert_property(
243        &mut properties,
244        "headers",
245        headers_type(),
246        "/** Response headers. Keys lowercased on the wire so `headers.get(\"content-type\")` works regardless of how the server cased them. */",
247    );
248    insert_property(
249        &mut properties,
250        "ok",
251        Type::Boolean,
252        "/** `true` iff `status` is in the 2xx range. */",
253    );
254    insert_property(
255        &mut properties,
256        "status",
257        Type::Number,
258        "/** HTTP status code (e.g. `200`, `404`). */",
259    );
260    insert_property(
261        &mut properties,
262        "statusText",
263        Type::String,
264        "/** Reason phrase (e.g. `\"OK\"`, `\"Not Found\"`). Empty when the server doesn't supply one. */",
265    );
266    insert_property(
267        &mut properties,
268        "url",
269        Type::String,
270        "/** Final URL after any redirects. Equals the original URL if there were no redirects. */",
271    );
272
273    let mut methods = BTreeMap::new();
274    methods.insert(
275        "throwForStatus".to_string(),
276        MethodSig {
277            optional: false,
278            generics: Default::default(),
279            params: Vec::new(),
280            ret: Type::Void,
281            predicate: None,
282            doc: crate::doc(crate::FileId::HTTP,
283                "/** Throw if `ok` is `false`. The error message includes the status code, reason phrase, and final URL; catch it with `try`/`catch` like any other `Error`. */",
284            ),
285        },
286    );
287    methods.insert(
288        "toString".to_string(),
289        MethodSig {
290            optional: false,
291            generics: Default::default(),
292            params: Vec::new(),
293            ret: Type::String,
294            predicate: None,
295            doc: crate::doc(crate::FileId::HTTP,
296                "/** Concise description of the response — `\"Response(200 OK, https://...)\"`. Built host-side for efficiency. */",
297            ),
298        },
299    );
300    // Runtime-generic: the call passes `T`'s type descriptor, and the host
301    // parses the body and checks it with the descriptor's predicate.
302    methods.insert(
303        RESPONSE_JSON.to_string(),
304        MethodSig {
305            optional: false,
306            generics: crate::GenericParameters::unconstrained(vec!["T".to_string()]),
307            params: Vec::new(),
308            ret: Type::TypeVar("T".to_string()),
309            predicate: None,
310            doc: crate::doc(crate::FileId::HTTP,
311                "/** Parse the response body as JSON and check it against `T`, for example `r.json<User>()`. The check covers every field, element, and union arm; a body that does not match throws a catchable `TypeError` naming the type, a body that is not JSON throws a `SyntaxError`, and a number past the `number` range throws a `RangeError`. The expected type supplies `T` when none is written (`const user: User = r.json()`); with neither, the result is an unchecked `unknown`. */",
312            ),
313        },
314    );
315
316    defs.types.insert(
317        "Response".to_string(),
318        TypeSymbol {
319            name: "Response".to_string(),
320            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "Response"),
321            declaration_span: Span::at(crate::FileId::HTTP),
322            kind: TypeKind::Interface { index: None,
323                generics: Default::default(),
324                methods,
325                properties,
326                dispatch: Dispatch::Direct,
327                doc: crate::doc(crate::FileId::HTTP,
328                    "/** Result of an HTTP call — status, headers, body, and the final URL after any redirects. Constructed only by the verb-form helpers; user code reads fields via property access and calls `json<T>()`, `throwForStatus()` and `toString()`. */",
329                ),
330            },
331        },
332    );
333}
334
335fn insert_download_options_interface(defs: &mut PackageDeclaration) {
336    let mut properties = BTreeMap::new();
337    insert_optional_property(
338        &mut properties,
339        "overwrite",
340        Type::Boolean,
341        "/** When `true`, clobber an existing file at `path`. Default `false` — refuse with a trap, since LLMs retry operations and silent overwrites cause data loss. */",
342    );
343    insert_optional_property(
344        &mut properties,
345        "maxBytes",
346        Type::Number,
347        "/** Maximum bytes to write to disk (post-decompression when `decompress` is true). Default: the tier-configured global limit. The download traps with a `response too large` error if exceeded. */",
348    );
349    insert_optional_property(
350        &mut properties,
351        "headers",
352        optional_headers_type(),
353        "/** Request headers — typically used for auth (`Authorization: Bearer …`). */",
354    );
355    insert_optional_property(
356        &mut properties,
357        "timeout",
358        Type::Number,
359        "/** Per-request timeout in milliseconds. Default 60_000 (vs 30_000 for the verb-form helpers — downloads are usually larger). */",
360    );
361    insert_optional_property(
362        &mut properties,
363        "decompress",
364        Type::Boolean,
365        "/** When `true`, transparently gunzip / unzstd the response if the `Content-Encoding` header (or URL suffix `.gz` / `.zst`) advertises a compressed stream. Default `false`: downloading `data.csv.gz` saves the `.gz` file as-is. */",
366    );
367
368    defs.types.insert(
369        "DownloadOptions".to_string(),
370        TypeSymbol {
371            name: "DownloadOptions".to_string(),
372            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "DownloadOptions"),
373            declaration_span: Span::at(crate::FileId::HTTP),
374            kind: TypeKind::Interface { index: None,
375                generics: Default::default(),
376                methods: BTreeMap::new(),
377                properties,
378                // VTable: fields read via inline field-name scan, no per-field imports emitted.
379                dispatch: Dispatch::VTable,
380                doc: crate::doc(crate::FileId::HTTP,
381                    "/** Options bag for [`download`]. All fields optional. Pass `undefined` (or omit the param) to take every default. */",
382                ),
383            },
384        },
385    );
386}
387
388fn insert_optional_property(
389    properties: &mut BTreeMap<String, PropertySig>,
390    name: &str,
391    ty: Type,
392    doc: &str,
393) {
394    properties.insert(
395        name.to_string(),
396        PropertySig {
397            ty,
398            readonly: false,
399            intrinsic: false,
400            optional: true,
401            doc: crate::doc(crate::FileId::HTTP, doc),
402        },
403    );
404}
405
406fn insert_download_result_interface(defs: &mut PackageDeclaration) {
407    let mut properties = BTreeMap::new();
408    insert_property(
409        &mut properties,
410        "bytesWritten",
411        Type::Number,
412        "/** Bytes actually written to disk (post-decompression when `decompress` was set). */",
413    );
414    insert_property(
415        &mut properties,
416        "contentType",
417        Type::String,
418        "/** `Content-Type` header from the response, lowercased. Empty string when the server didn't send one. */",
419    );
420    insert_property(
421        &mut properties,
422        "duration_ms",
423        Type::Number,
424        "/** Wall-clock duration of the download in milliseconds, measured from temp-file open through atomic rename. */",
425    );
426    insert_property(
427        &mut properties,
428        "finalUrl",
429        Type::String,
430        "/** Final URL after any redirects. Equals the original URL if there were no redirects. */",
431    );
432    insert_property(
433        &mut properties,
434        "path",
435        Type::String,
436        "/** The VFS path the file was written to — echoes the `path` argument. */",
437    );
438    insert_property(
439        &mut properties,
440        "status",
441        Type::Number,
442        "/** HTTP status code (e.g. `200`). The download does not trap on non-2xx — a 404 still writes the response body to disk, on the theory that an HTML error page is sometimes what an agent wants. Check `status` to discriminate. */",
443    );
444
445    defs.types.insert(
446        "DownloadResult".to_string(),
447        TypeSymbol {
448            name: "DownloadResult".to_string(),
449            mangled_name: crate::mangle::package_symbol(MODULE_NAME, "DownloadResult"),
450            declaration_span: Span::at(crate::FileId::HTTP),
451            kind: TypeKind::Interface { index: None,
452                generics: Default::default(),
453                methods: BTreeMap::new(),
454                properties,
455                dispatch: Dispatch::VTable,
456                doc: crate::doc(crate::FileId::HTTP,
457                    "/** Result of [`download`] — what was written, where, and the response metadata. */",
458                ),
459            },
460        },
461    );
462}
463
464fn insert_property(
465    properties: &mut BTreeMap<String, PropertySig>,
466    name: &str,
467    ty: Type,
468    doc: &str,
469) {
470    properties.insert(
471        name.to_string(),
472        PropertySig {
473            ty,
474            readonly: true,
475            intrinsic: false,
476            optional: false,
477            doc: crate::doc(crate::FileId::HTTP, doc),
478        },
479    );
480}