//! `http::` Rhai bridge — outbound HTTP from scripts (v1.1.4). //! //! ```rhai //! let r = http::get("https://api.example.com/users/123"); //! let r = http::get(url, #{ headers: #{ "Authorization": "Bearer x" }, timeout_ms: 5000 }); //! let r = http::post(url, #{ text: "hello" }); // Map body → JSON //! let r = http::post(url, "raw", #{ headers: #{ ... } }); // String body → text/plain //! let r = http::post_form(url, #{ a: "1", b: "2" }); // form-encoded //! let r = http::request("OPTIONS", url); //! ``` //! //! **Argument shape (v1.1.4 decision):** body and options are separate //! positional arguments — `verb(url, body, opts)` — not body-inside- //! opts. This keeps the unknown-opt-key typo guard intact and resolves //! the brief's internal contradiction (its Slack example passed a bare //! body map). The `opts` vocabulary is exactly //! `{headers, timeout_ms, follow_redirects, max_redirects}`; any other //! key throws. //! //! Body dispatch (positional `body`): Map/Array → JSON + //! `application/json`; String → raw + `text/plain`; Unit `()` → no //! body. GET/HEAD ignore any body. //! //! Response is a Rhai map `#{ status, headers, body, body_raw }`: //! `body` is the parsed JSON when the response is `application/json` //! and parses; `()` for an empty body; otherwise the raw string. //! //! Errors follow `docs/sdk-shape.md`: network/timeout/SSRF/size failures //! throw (`"http: "`); a non-2xx status does NOT throw — the //! response map is returned, fetch-style. use std::collections::BTreeMap; use std::sync::Arc; use picloud_shared::{HttpRequest, HttpResponse, HttpService, SdkCallCx, Services}; use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module}; use super::bridge::{ block_on, dynamic_to_json_capped, json_to_dynamic, runtime_err, MAX_JSON_MATERIALIZE_BYTES, }; use super::interceptor::InterceptorCtx; /// Bridge-side defaults (the service clamps server-side too). The /// `MAX_*` ceilings stay `i64` because they're compared against the /// raw `i64` the script passed (so an over-limit value is rejected, not /// truncated); the defaults are `u32` to match the `Opts` fields. const DEFAULT_TIMEOUT_MS: u32 = 30_000; const MAX_TIMEOUT_MS: i64 = 60_000; const DEFAULT_MAX_REDIRECTS: u32 = 5; const MAX_REDIRECTS: i64 = 10; const ALLOWED_OPT_KEYS: [&str; 4] = ["headers", "timeout_ms", "follow_redirects", "max_redirects"]; // `http` registers its verbs as module native fns (no handle struct), so the // interceptor ctx is captured into each request closure directly. Every verb // funnels through `invoke` / `invoke_form`, which run the §9.4 before/after hook // around `svc.request` (M11). The hook sees `service = "http"`, `op = // "request"`, `collection = `, `key = `; the body is never // surfaced, so there is no data transform. pub(super) fn register( engine: &mut RhaiEngine, services: &Services, cx: Arc, ictx: InterceptorCtx, ) { let svc = services.http.clone(); let mut module = Module::new(); // Bodyless verbs: (url) / (url, opts). for verb in ["get", "head"] { register_bodyless(&mut module, verb, &svc, &cx, &ictx); } // Body verbs: (url) / (url, body) / (url, body, opts). for verb in ["post", "put", "patch", "delete"] { register_body(&mut module, verb, &svc, &cx, &ictx); } register_post_form(&mut module, &svc, &cx, &ictx); register_request(&mut module, &svc, &cx, &ictx); engine.register_static_module("http", module.into()); } fn register_bodyless( module: &mut Module, verb: &'static str, svc: &Arc, cx: &Arc, ictx: &InterceptorCtx, ) { { let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone()); module.set_native_fn(verb, move |url: &str| { invoke(&ictx, &svc, &cx, verb, url, None, None) }); } { let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone()); module.set_native_fn(verb, move |url: &str, opts: Map| { invoke(&ictx, &svc, &cx, verb, url, None, Some(&opts)) }); } } fn register_body( module: &mut Module, verb: &'static str, svc: &Arc, cx: &Arc, ictx: &InterceptorCtx, ) { { let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone()); module.set_native_fn(verb, move |url: &str| { invoke(&ictx, &svc, &cx, verb, url, None, None) }); } { let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone()); module.set_native_fn(verb, move |url: &str, body: Dynamic| { invoke(&ictx, &svc, &cx, verb, url, Some(body), None) }); } { let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone()); module.set_native_fn(verb, move |url: &str, body: Dynamic, opts: Map| { invoke(&ictx, &svc, &cx, verb, url, Some(body), Some(&opts)) }); } } fn register_post_form( module: &mut Module, svc: &Arc, cx: &Arc, ictx: &InterceptorCtx, ) { { let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone()); module.set_native_fn("post_form", move |url: &str, form: Map| { invoke_form(&ictx, &svc, &cx, url, &form, None) }); } { let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone()); module.set_native_fn("post_form", move |url: &str, form: Map, opts: Map| { invoke_form(&ictx, &svc, &cx, url, &form, Some(&opts)) }); } } fn register_request( module: &mut Module, svc: &Arc, cx: &Arc, ictx: &InterceptorCtx, ) { { let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone()); module.set_native_fn("request", move |method: &str, url: &str| { invoke(&ictx, &svc, &cx, method, url, None, None) }); } { let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone()); module.set_native_fn("request", move |method: &str, url: &str, body: Dynamic| { invoke(&ictx, &svc, &cx, method, url, Some(body), None) }); } { let (svc, cx, ictx) = (svc.clone(), cx.clone(), ictx.clone()); module.set_native_fn( "request", move |method: &str, url: &str, body: Dynamic, opts: Map| { invoke(&ictx, &svc, &cx, method, url, Some(body), Some(&opts)) }, ); } } /// Parsed `opts` map. struct Opts { headers: BTreeMap, timeout_ms: u32, follow_redirects: bool, max_redirects: u32, } impl Default for Opts { fn default() -> Self { Self { headers: BTreeMap::new(), timeout_ms: DEFAULT_TIMEOUT_MS, follow_redirects: true, max_redirects: DEFAULT_MAX_REDIRECTS, } } } fn parse_opts(opts: Option<&Map>) -> Result> { let mut out = Opts::default(); let Some(map) = opts else { return Ok(out); }; for key in map.keys() { if !ALLOWED_OPT_KEYS.contains(&key.as_str()) { return Err(err(format!("unknown option key: {key}"))); } } if let Some(h) = map.get("headers") { let hm = h .clone() .try_cast::() .ok_or_else(|| err("headers must be a map".to_string()))?; for (k, v) in hm { out.headers.insert(k.to_string(), dyn_to_string(&v)); } } if let Some(t) = map.get("timeout_ms") { let ms = t .as_int() .map_err(|_| err("timeout_ms must be an integer".to_string()))?; if ms > MAX_TIMEOUT_MS { return Err(err(format!( "timeout_ms {ms} exceeds the {MAX_TIMEOUT_MS}ms maximum" ))); } if ms > 0 { out.timeout_ms = u32::try_from(ms).unwrap_or(u32::MAX); } } if let Some(f) = map.get("follow_redirects") { out.follow_redirects = f .as_bool() .map_err(|_| err("follow_redirects must be a bool".to_string()))?; } if let Some(m) = map.get("max_redirects") { let n = m .as_int() .map_err(|_| err("max_redirects must be an integer".to_string()))?; if n > MAX_REDIRECTS { return Err(err(format!( "max_redirects {n} exceeds the {MAX_REDIRECTS} maximum" ))); } out.max_redirects = u32::try_from(n.max(0)).unwrap_or(0); } Ok(out) } /// Encoded request body + the content-type chosen for it. type EncodedBody = (Option>, Option); /// Dispatch a positional body by Rhai type. Returns the encoded bytes + /// the chosen content-type. GET/HEAD callers pass `body = None`, so /// this is never reached for them. fn dispatch_body(body: Dynamic) -> Result> { if body.is_unit() { return Ok((None, None)); } if body.is_string() { let s = body.into_string().unwrap_or_default(); return Ok((Some(s.into_bytes()), Some("text/plain".to_string()))); } if body.is_map() || body.is_array() { let json = dynamic_to_json_capped(&body, MAX_JSON_MATERIALIZE_BYTES)?; let bytes = serde_json::to_vec(&json) .map_err(|e| err(format!("could not encode JSON body: {e}")))?; return Ok((Some(bytes), Some("application/json".to_string()))); } // Scalars (int/float/bool) → JSON-encode for consistency. let json = dynamic_to_json_capped(&body, MAX_JSON_MATERIALIZE_BYTES)?; let bytes = serde_json::to_vec(&json).map_err(|e| err(format!("could not encode body: {e}")))?; Ok((Some(bytes), Some("application/json".to_string()))) } #[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)] fn invoke( ictx: &InterceptorCtx, svc: &Arc, cx: &Arc, method: &str, url: &str, body: Option, opts: Option<&Map>, ) -> Result> { let opts = parse_opts(opts)?; let method_uc = method.to_ascii_uppercase(); // §9.4 before-op interceptor (allow/deny only; the body is not surfaced to // the hook). `collection` = the HTTP method, `key` = the URL. super::interceptor::run_before(ictx, cx, "http", "request", &method_uc, url, None)?; let bodyless = matches!(method_uc.as_str(), "GET" | "HEAD"); let (encoded, content_type) = if bodyless { (None, None) } else if let Some(b) = body { dispatch_body(b)? } else { (None, None) }; let req = HttpRequest { method: method_uc.clone(), url: url.to_string(), headers: opts.headers, body: encoded, content_type, timeout_ms: opts.timeout_ms, follow_redirects: opts.follow_redirects, max_redirects: opts.max_redirects, script_id: Some(cx.script_id.to_string()), }; let resp = { let svc = svc.clone(); let cx = cx.clone(); block_on("http", async move { svc.request(&cx, req).await })? }; super::interceptor::run_after( ictx, cx, "http", "request", &method_uc, url, None, serde_json::Value::Null, )?; Ok(response_to_dynamic(&resp)) } #[allow(clippy::needless_pass_by_value)] fn invoke_form( ictx: &InterceptorCtx, svc: &Arc, cx: &Arc, url: &str, form: &Map, opts: Option<&Map>, ) -> Result> { let opts = parse_opts(opts)?; // §9.4 before-op interceptor. `post_form` is always a POST. super::interceptor::run_before(ictx, cx, "http", "request", "POST", url, None)?; let mut serializer = url::form_urlencoded::Serializer::new(String::new()); for (k, v) in form { serializer.append_pair(k.as_str(), &dyn_to_string(v)); } let encoded = serializer.finish(); let req = HttpRequest { method: "POST".to_string(), url: url.to_string(), headers: opts.headers, body: Some(encoded.into_bytes()), content_type: Some("application/x-www-form-urlencoded".to_string()), timeout_ms: opts.timeout_ms, follow_redirects: opts.follow_redirects, max_redirects: opts.max_redirects, script_id: Some(cx.script_id.to_string()), }; let resp = { let svc = svc.clone(); let cx = cx.clone(); block_on("http", async move { svc.request(&cx, req).await })? }; super::interceptor::run_after( ictx, cx, "http", "request", "POST", url, None, serde_json::Value::Null, )?; Ok(response_to_dynamic(&resp)) } fn response_to_dynamic(resp: &HttpResponse) -> Dynamic { let mut m = Map::new(); m.insert("status".into(), i64::from(resp.status).into()); let mut headers = Map::new(); let mut content_type = String::new(); for (k, v) in &resp.headers { if k == "content-type" { content_type.clone_from(v); } headers.insert(k.clone().into(), v.clone().into()); } m.insert("headers".into(), headers.into()); // `body`: parsed JSON when the response is JSON and parses; () when // empty; otherwise the raw string. let body = if resp.body_raw.is_empty() { Dynamic::UNIT } else if content_type .to_ascii_lowercase() .starts_with("application/json") { match serde_json::from_str::(&resp.body_raw) { Ok(json) => json_to_dynamic(json), Err(_) => resp.body_raw.clone().into(), } } else { resp.body_raw.clone().into() }; m.insert("body".into(), body); m.insert("body_raw".into(), resp.body_raw.clone().into()); m.into() } fn dyn_to_string(v: &Dynamic) -> String { if v.is_string() { v.clone().into_string().unwrap_or_default() } else { v.to_string() } } // A validation error, prefixed like the service's runtime errors (the shared // `bridge::block_on("http", …)` prefixes the same way, so messages are uniform). // Delegates to `bridge::runtime_err`; the boxed return is Rhai's error channel. #[allow(clippy::unnecessary_box_returns)] fn err(msg: String) -> Box { runtime_err(&format!("http: {msg}")) }