Files
PiCloud/crates/executor-core/src/sdk/http.rs
MechaCat02 ef16d48a8c feat(interceptors): extend before/after hooks to docs/files/queue/pubsub/http (§9.4 M7-M11)
Every non-KV mutating op now runs the same before/after interceptor machinery:
- docs create/update/delete (+ group) — create/update honor the M4 data transform;
- files create/update/delete (+ group) — allow/deny only (the blob is never
  surfaced to the hook, value = None);
- queue enqueue (+ shared) — transform-capable; after reports the message id;
- pubsub publish_durable (+ shared topic) — transform-capable;
- http request — all verbs funnel through two svc.request sites; collection =
  method, key = url, body not surfaced.
ictx threaded into the free-fn/handle paths that lacked it (http was _ictx;
queue/pubsub per-app publish).

validate_bundle_for generalized to a per-service allowed-ops map (kv set/delete;
docs/files create/update/delete; queue enqueue; pubsub publish; http request) —
unknown service/op still rejected. INVARIANT enforced + verified: every allowed
(service, op) has a matching runtime hook, so no validated-but-unhooked
fail-open. Pinned by a new docs-create deny journey (11 interceptor journeys green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00

426 lines
14 KiB
Rust

//! `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: <message>"`); 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 = <METHOD>`, `key = <url>`; the body is never
// surfaced, so there is no data transform.
pub(super) fn register(
engine: &mut RhaiEngine,
services: &Services,
cx: Arc<SdkCallCx>,
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<dyn HttpService>,
cx: &Arc<SdkCallCx>,
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<dyn HttpService>,
cx: &Arc<SdkCallCx>,
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<dyn HttpService>,
cx: &Arc<SdkCallCx>,
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<dyn HttpService>,
cx: &Arc<SdkCallCx>,
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<String, String>,
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<Opts, Box<EvalAltResult>> {
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::<Map>()
.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<Vec<u8>>, Option<String>);
/// 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<EncodedBody, Box<EvalAltResult>> {
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<dyn HttpService>,
cx: &Arc<SdkCallCx>,
method: &str,
url: &str,
body: Option<Dynamic>,
opts: Option<&Map>,
) -> Result<Dynamic, Box<EvalAltResult>> {
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<dyn HttpService>,
cx: &Arc<SdkCallCx>,
url: &str,
form: &Map,
opts: Option<&Map>,
) -> Result<Dynamic, Box<EvalAltResult>> {
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::<serde_json::Value>(&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<EvalAltResult> {
runtime_err(&format!("http: {msg}"))
}