feat(stdlib): seven Rhai utility modules + register_stdlib hook
Adds the v1.1.0 user-visible stdlib: regex, random, time, json, base64, hex, url — each exposed as a `::` namespace mirroring the existing `log::` pattern. Modules register once at engine build via `Engine::register_static_module`, distinct from the stateful service modules (KV, docs, …) that hook into `sdk::register_all` per call. - regex: linear-time, compile-per-call (no cache by design) - random: OsRng only; bytes/string capped to prevent script-side blow-up - time: UTC, ms-since-epoch as canonical i64; RFC 3339 strings for I/O - json: parse/stringify via existing dynamic<->json bridge - base64: standard + URL-safe alphabets, Blob and String inputs - hex: lowercase output, case-insensitive decode - url: RFC 3986 percent-encoding + encode_query for Maps Stdlib registration runs unconditionally — including in the parse-only validate path — so scripts get a uniform surface in both phases. See docs/sdk-shape.md for the stateless-vs-stateful distinction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -143,6 +143,11 @@ fn build_engine(limits: Limits, logs: Option<Arc<Mutex<Vec<LogEntry>>>>) -> Rhai
|
|||||||
engine.register_static_module("log", build_log_module(logs).into());
|
engine.register_static_module("log", build_log_module(logs).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stateless utility modules — regex::/random::/time::/json::/base64::/
|
||||||
|
// hex::/url::. Always registered, including in the parse-only validate
|
||||||
|
// path, so script authors get consistent surface in both phases.
|
||||||
|
sdk::stdlib::register_stdlib(&mut engine);
|
||||||
|
|
||||||
engine
|
engine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
pub mod bridge;
|
pub mod bridge;
|
||||||
pub mod cx;
|
pub mod cx;
|
||||||
|
pub mod stdlib;
|
||||||
|
|
||||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||||
pub use cx::SdkCallCx;
|
pub use cx::SdkCallCx;
|
||||||
|
|||||||
48
crates/executor-core/src/sdk/stdlib/base64.rs
Normal file
48
crates/executor-core/src/sdk/stdlib/base64.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//! `base64::` — standard and URL-safe Base64.
|
||||||
|
//!
|
||||||
|
//! Two encoders are exposed: standard alphabet with padding (`encode`/
|
||||||
|
//! `decode`) and URL-safe alphabet without padding (`encode_url`/
|
||||||
|
//! `decode_url`). Each encoder accepts both `String` and `Blob` inputs
|
||||||
|
//! as separate Rhai overloads; decoders always return `Blob` — the
|
||||||
|
//! caller knows whether the original bytes were textual.
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||||||
|
use base64::Engine as _;
|
||||||
|
use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module};
|
||||||
|
|
||||||
|
pub fn register(engine: &mut RhaiEngine) {
|
||||||
|
let mut module = Module::new();
|
||||||
|
|
||||||
|
module.set_native_fn("encode", |s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
Ok(STANDARD.encode(s.as_bytes()))
|
||||||
|
});
|
||||||
|
module.set_native_fn("encode", |b: Blob| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
Ok(STANDARD.encode(&b))
|
||||||
|
});
|
||||||
|
module.set_native_fn("decode", |s: &str| -> Result<Blob, Box<EvalAltResult>> {
|
||||||
|
STANDARD
|
||||||
|
.decode(s)
|
||||||
|
.map_err(|e| format!("base64::decode: {e}").into())
|
||||||
|
});
|
||||||
|
|
||||||
|
module.set_native_fn(
|
||||||
|
"encode_url",
|
||||||
|
|s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
Ok(URL_SAFE_NO_PAD.encode(s.as_bytes()))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
module.set_native_fn(
|
||||||
|
"encode_url",
|
||||||
|
|b: Blob| -> Result<String, Box<EvalAltResult>> { Ok(URL_SAFE_NO_PAD.encode(&b)) },
|
||||||
|
);
|
||||||
|
module.set_native_fn(
|
||||||
|
"decode_url",
|
||||||
|
|s: &str| -> Result<Blob, Box<EvalAltResult>> {
|
||||||
|
URL_SAFE_NO_PAD
|
||||||
|
.decode(s)
|
||||||
|
.map_err(|e| format!("base64::decode_url: {e}").into())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
engine.register_static_module("base64", module.into());
|
||||||
|
}
|
||||||
21
crates/executor-core/src/sdk/stdlib/hex.rs
Normal file
21
crates/executor-core/src/sdk/stdlib/hex.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//! `hex::` — hexadecimal encode/decode (lowercase output, case-
|
||||||
|
//! insensitive input). String and Blob inputs are both accepted on
|
||||||
|
//! encode; decode always returns `Blob`.
|
||||||
|
|
||||||
|
use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module};
|
||||||
|
|
||||||
|
pub fn register(engine: &mut RhaiEngine) {
|
||||||
|
let mut module = Module::new();
|
||||||
|
|
||||||
|
module.set_native_fn("encode", |s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
Ok(hex::encode(s.as_bytes()))
|
||||||
|
});
|
||||||
|
module.set_native_fn("encode", |b: Blob| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
Ok(hex::encode(&b))
|
||||||
|
});
|
||||||
|
module.set_native_fn("decode", |s: &str| -> Result<Blob, Box<EvalAltResult>> {
|
||||||
|
hex::decode(s).map_err(|e| format!("hex::decode: {e}").into())
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.register_static_module("hex", module.into());
|
||||||
|
}
|
||||||
43
crates/executor-core/src/sdk/stdlib/json.rs
Normal file
43
crates/executor-core/src/sdk/stdlib/json.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//! `json::` — JSON parse and stringify. Reuses the bridge functions in
|
||||||
|
//! `crate::sdk::bridge` so script-visible JSON has the same shape
|
||||||
|
//! (numbers, maps, arrays, nulls) as `ctx.request.body` already does.
|
||||||
|
|
||||||
|
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
|
||||||
|
|
||||||
|
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
|
||||||
|
|
||||||
|
pub fn register(engine: &mut RhaiEngine) {
|
||||||
|
let mut module = Module::new();
|
||||||
|
register_parse(&mut module);
|
||||||
|
register_stringify(&mut module);
|
||||||
|
register_stringify_pretty(&mut module);
|
||||||
|
engine.register_static_module("json", module.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_parse(module: &mut Module) {
|
||||||
|
module.set_native_fn("parse", |s: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(s).map_err(|e| format!("json::parse: {e}"))?;
|
||||||
|
Ok(json_to_dynamic(value))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_stringify(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"stringify",
|
||||||
|
|v: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
serde_json::to_string(&dynamic_to_json(&v))
|
||||||
|
.map_err(|e| format!("json::stringify: {e}").into())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_stringify_pretty(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"stringify_pretty",
|
||||||
|
|v: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
serde_json::to_string_pretty(&dynamic_to_json(&v))
|
||||||
|
.map_err(|e| format!("json::stringify_pretty: {e}").into())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
25
crates/executor-core/src/sdk/stdlib/mod.rs
Normal file
25
crates/executor-core/src/sdk/stdlib/mod.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//! Stateless utility modules registered once at engine build via
|
||||||
|
//! `Engine::register_static_module`. They have no per-call state, no
|
||||||
|
//! cross-app sensitivity, and no `SdkCallCx` — distinguishing them
|
||||||
|
//! from stateful service modules (KV, docs, …) which hook into
|
||||||
|
//! `sdk::register_all` instead. See [docs/sdk-shape.md](../../../../../docs/sdk-shape.md).
|
||||||
|
|
||||||
|
use rhai::Engine as RhaiEngine;
|
||||||
|
|
||||||
|
pub mod base64;
|
||||||
|
pub mod hex;
|
||||||
|
pub mod json;
|
||||||
|
pub mod random;
|
||||||
|
pub mod regex;
|
||||||
|
pub mod time;
|
||||||
|
pub mod url;
|
||||||
|
|
||||||
|
pub fn register_stdlib(engine: &mut RhaiEngine) {
|
||||||
|
regex::register(engine);
|
||||||
|
random::register(engine);
|
||||||
|
time::register(engine);
|
||||||
|
json::register(engine);
|
||||||
|
base64::register(engine);
|
||||||
|
hex::register(engine);
|
||||||
|
url::register(engine);
|
||||||
|
}
|
||||||
70
crates/executor-core/src/sdk/stdlib/random.rs
Normal file
70
crates/executor-core/src/sdk/stdlib/random.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
//! `random::` — CSPRNG primitives (`rand::rngs::OsRng`).
|
||||||
|
//!
|
||||||
|
//! Only the OS RNG is exposed. No "fast non-crypto" variant — scripts
|
||||||
|
//! should not pick between secure and insecure entropy. Output sizes
|
||||||
|
//! are capped to keep a single script call from blowing host memory.
|
||||||
|
|
||||||
|
use rand::distributions::{Alphanumeric, DistString};
|
||||||
|
use rand::{rngs::OsRng, Rng, RngCore};
|
||||||
|
use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const MAX_BYTES: i64 = 65_536;
|
||||||
|
const MAX_STRING: i64 = 4_096;
|
||||||
|
|
||||||
|
pub fn register(engine: &mut RhaiEngine) {
|
||||||
|
let mut module = Module::new();
|
||||||
|
register_int(&mut module);
|
||||||
|
register_float(&mut module);
|
||||||
|
register_bytes(&mut module);
|
||||||
|
register_string(&mut module);
|
||||||
|
register_uuid(&mut module);
|
||||||
|
engine.register_static_module("random", module.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_int(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"int",
|
||||||
|
|min: i64, max: i64| -> Result<i64, Box<EvalAltResult>> {
|
||||||
|
if min > max {
|
||||||
|
return Err(format!("random::int: min ({min}) > max ({max})").into());
|
||||||
|
}
|
||||||
|
Ok(OsRng.gen_range(min..=max))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_float(module: &mut Module) {
|
||||||
|
module.set_native_fn("float", || -> Result<f64, Box<EvalAltResult>> {
|
||||||
|
Ok(OsRng.gen::<f64>())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_bytes(module: &mut Module) {
|
||||||
|
module.set_native_fn("bytes", |n: i64| -> Result<Blob, Box<EvalAltResult>> {
|
||||||
|
if !(0..=MAX_BYTES).contains(&n) {
|
||||||
|
return Err(format!("random::bytes: n must be in 0..={MAX_BYTES}, got {n}").into());
|
||||||
|
}
|
||||||
|
// Safe: n is non-negative and bounded by MAX_BYTES, which fits in usize.
|
||||||
|
let len = usize::try_from(n).expect("n bounded above by MAX_BYTES");
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
OsRng.fill_bytes(&mut buf);
|
||||||
|
Ok(buf)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_string(module: &mut Module) {
|
||||||
|
module.set_native_fn("string", |n: i64| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
if !(0..=MAX_STRING).contains(&n) {
|
||||||
|
return Err(format!("random::string: n must be in 0..={MAX_STRING}, got {n}").into());
|
||||||
|
}
|
||||||
|
let len = usize::try_from(n).expect("n bounded above by MAX_STRING");
|
||||||
|
Ok(Alphanumeric.sample_string(&mut OsRng, len))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_uuid(module: &mut Module) {
|
||||||
|
module.set_native_fn("uuid", || -> Result<String, Box<EvalAltResult>> {
|
||||||
|
Ok(Uuid::new_v4().to_string())
|
||||||
|
});
|
||||||
|
}
|
||||||
105
crates/executor-core/src/sdk/stdlib/regex.rs
Normal file
105
crates/executor-core/src/sdk/stdlib/regex.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
//! `regex::` — non-backtracking regular expressions (Rust `regex` crate).
|
||||||
|
//!
|
||||||
|
//! Patterns compile per call. No cache: premature for v1.1.0, and the
|
||||||
|
//! `regex` crate's linear-time guarantees keep per-call cost bounded.
|
||||||
|
//! Catastrophic patterns are rejected at compile time by the crate
|
||||||
|
//! itself; no extra defense needed.
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
|
||||||
|
|
||||||
|
pub fn register(engine: &mut RhaiEngine) {
|
||||||
|
let mut module = Module::new();
|
||||||
|
register_is_match(&mut module);
|
||||||
|
register_find(&mut module);
|
||||||
|
register_find_all(&mut module);
|
||||||
|
register_replace(&mut module);
|
||||||
|
register_replace_all(&mut module);
|
||||||
|
register_split(&mut module);
|
||||||
|
register_captures(&mut module);
|
||||||
|
engine.register_static_module("regex", module.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile(pattern: &str) -> Result<Regex, Box<EvalAltResult>> {
|
||||||
|
Regex::new(pattern).map_err(|e| format!("invalid regex: {e}").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_is_match(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"is_match",
|
||||||
|
|pattern: &str, text: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
Ok(compile(pattern)?.is_match(text))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_find(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"find",
|
||||||
|
|pattern: &str, text: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
Ok(compile(pattern)?
|
||||||
|
.find(text)
|
||||||
|
.map_or(Dynamic::UNIT, |m| Dynamic::from(m.as_str().to_string())))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_find_all(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"find_all",
|
||||||
|
|pattern: &str, text: &str| -> Result<Array, Box<EvalAltResult>> {
|
||||||
|
Ok(compile(pattern)?
|
||||||
|
.find_iter(text)
|
||||||
|
.map(|m| Dynamic::from(m.as_str().to_string()))
|
||||||
|
.collect())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_replace(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"replace",
|
||||||
|
|pattern: &str, text: &str, replacement: &str| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
Ok(compile(pattern)?.replace(text, replacement).into_owned())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_replace_all(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"replace_all",
|
||||||
|
|pattern: &str, text: &str, replacement: &str| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
Ok(compile(pattern)?
|
||||||
|
.replace_all(text, replacement)
|
||||||
|
.into_owned())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_split(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"split",
|
||||||
|
|pattern: &str, text: &str| -> Result<Array, Box<EvalAltResult>> {
|
||||||
|
Ok(compile(pattern)?
|
||||||
|
.split(text)
|
||||||
|
.map(|s| Dynamic::from(s.to_string()))
|
||||||
|
.collect())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_captures(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"captures",
|
||||||
|
|pattern: &str, text: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let re = compile(pattern)?;
|
||||||
|
Ok(re.captures(text).map_or(Dynamic::UNIT, |caps| {
|
||||||
|
let arr: Array = caps
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.map_or(Dynamic::UNIT, |m| Dynamic::from(m.as_str().to_string())))
|
||||||
|
.collect();
|
||||||
|
Dynamic::from(arr)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
68
crates/executor-core/src/sdk/stdlib/time.rs
Normal file
68
crates/executor-core/src/sdk/stdlib/time.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
//! `time::` — UTC time. The canonical "time value" is milliseconds
|
||||||
|
//! since the Unix epoch as `i64`. ISO 8601 strings are for parsing and
|
||||||
|
//! display only. UTC only — no timezone support in v1.1.0 (would pull
|
||||||
|
//! in chrono-tz, deferred until a real use case demands it).
|
||||||
|
|
||||||
|
use chrono::{DateTime, SecondsFormat, Utc};
|
||||||
|
use rhai::{Engine as RhaiEngine, EvalAltResult, Module};
|
||||||
|
|
||||||
|
pub fn register(engine: &mut RhaiEngine) {
|
||||||
|
let mut module = Module::new();
|
||||||
|
register_now(&mut module);
|
||||||
|
register_now_ms(&mut module);
|
||||||
|
register_parse(&mut module);
|
||||||
|
register_format(&mut module);
|
||||||
|
register_add_seconds(&mut module);
|
||||||
|
register_diff_seconds(&mut module);
|
||||||
|
engine.register_static_module("time", module.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_now(module: &mut Module) {
|
||||||
|
module.set_native_fn("now", || -> Result<String, Box<EvalAltResult>> {
|
||||||
|
Ok(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_now_ms(module: &mut Module) {
|
||||||
|
module.set_native_fn("now_ms", || -> Result<i64, Box<EvalAltResult>> {
|
||||||
|
Ok(Utc::now().timestamp_millis())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_parse(module: &mut Module) {
|
||||||
|
module.set_native_fn("parse", |iso: &str| -> Result<i64, Box<EvalAltResult>> {
|
||||||
|
DateTime::parse_from_rfc3339(iso)
|
||||||
|
.map(|dt| dt.timestamp_millis())
|
||||||
|
.map_err(|e| format!("time::parse: invalid ISO 8601 / RFC 3339: {e}").into())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_format(module: &mut Module) {
|
||||||
|
module.set_native_fn("format", |ms: i64| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
DateTime::<Utc>::from_timestamp_millis(ms)
|
||||||
|
.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||||
|
.ok_or_else(|| format!("time::format: ms ({ms}) out of representable range").into())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_add_seconds(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"add_seconds",
|
||||||
|
|ms: i64, secs: i64| -> Result<i64, Box<EvalAltResult>> {
|
||||||
|
secs.checked_mul(1000)
|
||||||
|
.and_then(|delta| ms.checked_add(delta))
|
||||||
|
.ok_or_else(|| format!("time::add_seconds: overflow (ms={ms}, secs={secs})").into())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_diff_seconds(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"diff_seconds",
|
||||||
|
|a_ms: i64, b_ms: i64| -> Result<i64, Box<EvalAltResult>> {
|
||||||
|
b_ms.checked_sub(a_ms)
|
||||||
|
.map(|d| d / 1000)
|
||||||
|
.ok_or_else(|| format!("time::diff_seconds: overflow (a={a_ms}, b={b_ms})").into())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
64
crates/executor-core/src/sdk/stdlib/url.rs
Normal file
64
crates/executor-core/src/sdk/stdlib/url.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
//! `url::` — RFC 3986 percent-encoding.
|
||||||
|
//!
|
||||||
|
//! `encode`/`decode` operate on opaque component values; `encode_query`
|
||||||
|
//! builds an `application/x-www-form-urlencoded`-style query string
|
||||||
|
//! from a Rhai `Map`. Key ordering is the map's natural order (Rhai's
|
||||||
|
//! `Map` is a `BTreeMap`, so keys come out alphabetically — fine for
|
||||||
|
//! query strings, which RFC 3986 leaves unordered).
|
||||||
|
|
||||||
|
use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||||
|
use rhai::{Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
|
||||||
|
/// RFC 3986 unreserved set: `A-Z / a-z / 0-9 / - / _ / . / ~`.
|
||||||
|
/// Everything outside this set gets percent-encoded.
|
||||||
|
const UNRESERVED: &AsciiSet = &NON_ALPHANUMERIC
|
||||||
|
.remove(b'-')
|
||||||
|
.remove(b'_')
|
||||||
|
.remove(b'.')
|
||||||
|
.remove(b'~');
|
||||||
|
|
||||||
|
pub fn register(engine: &mut RhaiEngine) {
|
||||||
|
let mut module = Module::new();
|
||||||
|
register_encode(&mut module);
|
||||||
|
register_decode(&mut module);
|
||||||
|
register_encode_query(&mut module);
|
||||||
|
engine.register_static_module("url", module.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_encode(module: &mut Module) {
|
||||||
|
module.set_native_fn("encode", |s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
Ok(utf8_percent_encode(s, UNRESERVED).to_string())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_decode(module: &mut Module) {
|
||||||
|
module.set_native_fn("decode", |s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
percent_decode_str(s)
|
||||||
|
.decode_utf8()
|
||||||
|
.map(std::borrow::Cow::into_owned)
|
||||||
|
.map_err(|e| format!("url::decode: invalid UTF-8: {e}").into())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_encode_query(module: &mut Module) {
|
||||||
|
module.set_native_fn(
|
||||||
|
"encode_query",
|
||||||
|
|m: Map| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (k, v) in m {
|
||||||
|
if !out.is_empty() {
|
||||||
|
out.push('&');
|
||||||
|
}
|
||||||
|
out.push_str(&utf8_percent_encode(&k, UNRESERVED).to_string());
|
||||||
|
out.push('=');
|
||||||
|
// Coerce values via `to_string` rather than throwing on
|
||||||
|
// non-strings — scripts commonly pass numbers/bools here
|
||||||
|
// and a forced cast at the call site is friction with
|
||||||
|
// no upside.
|
||||||
|
let value = v.to_string();
|
||||||
|
out.push_str(&utf8_percent_encode(&value, UNRESERVED).to_string());
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user