use std::collections::VecDeque;
use std::time::Duration;
use base64::engine::general_purpose::STANDARD as BASE64_ENGINE;
use base64::Engine;
use runmat_builtins::{CellArray, CharArray, StructValue, Tensor, Value};
use runmat_macros::runtime_builtin;
use url::Url;
use super::transport::{
self, decode_body_as_text, header_value, HttpMethod, HttpRequest, HEADER_CONTENT_TYPE,
};
use crate::builtins::common::spec::{
BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
ReductionNaN, ResidencyPolicy, ShapeRequirements,
};
use crate::builtins::io::json::jsondecode::decode_json_text;
use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
const DEFAULT_TIMEOUT_SECONDS: f64 = 60.0;
const DEFAULT_USER_AGENT: &str = "RunMat webread/0.0";
#[allow(clippy::too_many_lines)]
#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::http::webread")]
pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
name: "webread",
op_kind: GpuOpKind::Custom("http-get"),
supported_precisions: &[],
broadcast: BroadcastSemantics::None,
provider_hooks: &[],
constant_strategy: ConstantStrategy::InlineLiteral,
residency: ResidencyPolicy::GatherImmediately,
nan_mode: ReductionNaN::Include,
two_pass_threshold: None,
workgroup_size: None,
accepts_nan_mode: false,
notes: "HTTP requests always execute on the CPU; gpuArray inputs are gathered eagerly.",
};
fn webread_error(message: impl Into<String>) -> RuntimeError {
build_runtime_error(message).with_builtin("webread").build()
}
fn remap_webread_flow<F>(err: RuntimeError, message: F) -> RuntimeError
where
F: FnOnce(&RuntimeError) -> String,
{
build_runtime_error(message(&err))
.with_builtin("webread")
.with_source(err)
.build()
}
fn webread_flow_with_context(err: RuntimeError) -> RuntimeError {
remap_webread_flow(err, |err| format!("webread: {}", err.message()))
}
#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::http::webread")]
pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
name: "webread",
shape: ShapeRequirements::Any,
constant_strategy: ConstantStrategy::InlineLiteral,
elementwise: None,
reduction: None,
emits_nan: false,
notes: "webread performs network I/O and terminates fusion graphs.",
};
#[runtime_builtin(
name = "webread",
category = "io/http",
summary = "Download web content (JSON, text, or binary) over HTTP/HTTPS.",
keywords = "webread,http get,rest client,json,api",
accel = "sink",
type_resolver(crate::builtins::io::type_resolvers::webread_type),
builtin_path = "crate::builtins::io::http::webread"
)]
async fn webread_builtin(url: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
let gathered_url = gather_if_needed_async(&url)
.await
.map_err(webread_flow_with_context)?;
let gathered_args = gather_arguments(rest).await?;
let url_text = expect_string_scalar(
&gathered_url,
"webread: URL must be a character vector or string scalar",
)?;
if url_text.trim().is_empty() {
return Err(webread_error("webread: URL must not be empty"));
}
let (options, query_params) = parse_arguments(gathered_args)?;
execute_request(&url_text, options, &query_params)
}
async fn gather_arguments(values: Vec<Value>) -> BuiltinResult<Vec<Value>> {
let mut out = Vec::with_capacity(values.len());
for value in values {
out.push(
gather_if_needed_async(&value)
.await
.map_err(webread_flow_with_context)?,
);
}
Ok(out)
}
fn parse_arguments(args: Vec<Value>) -> BuiltinResult<(WebReadOptions, Vec<(String, String)>)> {
let mut queue: VecDeque<Value> = args.into();
let mut options = WebReadOptions::default();
let mut query_params = Vec::new();
if matches!(queue.front(), Some(Value::Struct(_))) {
if let Some(Value::Struct(struct_value)) = queue.pop_front() {
process_struct_fields(&struct_value, &mut options, &mut query_params)?;
}
} else if matches!(queue.front(), Some(Value::Cell(_))) {
if let Some(Value::Cell(cell)) = queue.pop_front() {
append_query_from_cell(&cell, &mut query_params)?
}
}
while let Some(name_value) = queue.pop_front() {
let name = expect_string_scalar(
&name_value,
"webread: parameter names must be character vectors or string scalars",
)?;
let value = queue
.pop_front()
.ok_or_else(|| webread_error("webread: missing value for name-value argument"))?;
process_name_value_pair(&name, &value, &mut options, &mut query_params)?;
}
Ok((options, query_params))
}
fn process_struct_fields(
struct_value: &StructValue,
options: &mut WebReadOptions,
query_params: &mut Vec<(String, String)>,
) -> BuiltinResult<()> {
for (key, value) in &struct_value.fields {
process_name_value_pair(key, value, options, query_params)?;
}
Ok(())
}
fn process_name_value_pair(
name: &str,
value: &Value,
options: &mut WebReadOptions,
query_params: &mut Vec<(String, String)>,
) -> BuiltinResult<()> {
let lower = name.to_ascii_lowercase();
match lower.as_str() {
"contenttype" => {
options.content_type = parse_content_type(value)?;
Ok(())
}
"timeout" => {
options.timeout = parse_timeout(value)?;
Ok(())
}
"headerfields" => {
let headers = parse_header_fields(value)?;
options.headers.extend(headers);
Ok(())
}
"useragent" => {
options.user_agent = Some(expect_string_scalar(
value,
"webread: UserAgent must be a character vector or string scalar",
)?);
Ok(())
}
"username" => {
options.username = Some(expect_string_scalar(
value,
"webread: Username must be a character vector or string scalar",
)?);
Ok(())
}
"password" => {
options.password = Some(expect_string_scalar(
value,
"webread: Password must be a character vector or string scalar",
)?);
Ok(())
}
"requestmethod" => {
options.method = parse_request_method(value)?;
Ok(())
}
"mediatype" => {
expect_string_scalar(
value,
"webread: MediaType must be a character vector or string scalar",
)?;
Ok(())
}
"queryparameters" => append_query_from_value(value, query_params),
_ => {
let param_value = value_to_query_string(value, name)?;
query_params.push((name.to_string(), param_value));
Ok(())
}
}
}
fn append_query_from_value(
value: &Value,
query_params: &mut Vec<(String, String)>,
) -> BuiltinResult<()> {
match value {
Value::Struct(struct_value) => {
for (key, val) in &struct_value.fields {
let text = value_to_query_string(val, key)?;
query_params.push((key.clone(), text));
}
Ok(())
}
Value::Cell(cell) => append_query_from_cell(cell, query_params),
_ => Err(webread_error(
"webread: QueryParameters must be a struct or cell array",
)),
}
}
fn append_query_from_cell(
cell: &CellArray,
query_params: &mut Vec<(String, String)>,
) -> BuiltinResult<()> {
if cell.cols != 2 {
return Err(webread_error(
"webread: cell array of query parameters must have two columns",
));
}
for row in 0..cell.rows {
let name_value = cell
.get(row, 0)
.map_err(|err| webread_error(format!("webread: {err}")))?;
let value_value = cell
.get(row, 1)
.map_err(|err| webread_error(format!("webread: {err}")))?;
let name = expect_string_scalar(
&name_value,
"webread: query parameter names must be text scalars",
)?;
let text = value_to_query_string(&value_value, &name)?;
query_params.push((name, text));
}
Ok(())
}
fn execute_request(
url_text: &str,
options: WebReadOptions,
query_params: &[(String, String)],
) -> BuiltinResult<Value> {
let username_present = options
.username
.as_ref()
.map(|s| !s.is_empty())
.unwrap_or(false);
let password_present = options
.password
.as_ref()
.map(|s| !s.is_empty())
.unwrap_or(false);
if password_present && !username_present {
return Err(webread_error(
"webread: Password requires a Username option",
));
}
let mut url = Url::parse(url_text).map_err(|err| {
build_runtime_error(format!("webread: invalid URL '{url_text}': {err}"))
.with_builtin("webread")
.with_source(err)
.build()
})?;
if !query_params.is_empty() {
{
let mut pairs = url.query_pairs_mut();
for (name, value) in query_params {
pairs.append_pair(name, value);
}
}
}
let user_agent = options
.user_agent
.as_deref()
.filter(|ua| !ua.trim().is_empty())
.unwrap_or(DEFAULT_USER_AGENT)
.to_string();
let mut headers = options.headers.clone();
let has_auth_header = headers
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case("authorization"));
if !has_auth_header {
if let Some(username) = options.username.as_ref().filter(|s| !s.is_empty()) {
let password = options.password.clone().unwrap_or_default();
let token = BASE64_ENGINE.encode(format!("{username}:{password}"));
headers.push(("Authorization".to_string(), format!("Basic {token}")));
}
}
let request = HttpRequest {
url,
method: HttpMethod::Get,
headers,
body: None,
timeout: options.timeout,
user_agent,
};
let response = transport::send_request(&request).map_err(|err| {
build_runtime_error(err.message_with_prefix("webread"))
.with_builtin("webread")
.with_source(err)
.build()
})?;
let header_content_type =
header_value(&response.headers, HEADER_CONTENT_TYPE).map(|value| value.to_string());
let resolved = options.resolve_content_type(header_content_type.as_deref());
match resolved {
ResolvedContentType::Json => {
let body = decode_body_as_text(&response.body, header_content_type.as_deref());
let value = decode_json_text(&body).map_err(map_json_error)?;
Ok(value)
}
ResolvedContentType::Text => {
let text = decode_body_as_text(&response.body, header_content_type.as_deref());
let array = CharArray::new_row(&text);
Ok(Value::CharArray(array))
}
ResolvedContentType::Binary => {
let data: Vec<f64> = response.body.iter().map(|b| f64::from(*b)).collect();
let cols = response.body.len();
let tensor = Tensor::new(data, vec![1, cols])
.map_err(|err| webread_error(format!("webread: {err}")))?;
Ok(Value::Tensor(tensor))
}
}
}
fn map_json_error(err: RuntimeError) -> RuntimeError {
let message = if let Some(rest) = err.message().strip_prefix("jsondecode: ") {
format!("webread: failed to parse JSON response ({rest})")
} else {
format!("webread: failed to parse JSON response ({})", err.message())
};
build_runtime_error(message)
.with_builtin("webread")
.with_source(err)
.build()
}
fn parse_header_fields(value: &Value) -> BuiltinResult<Vec<(String, String)>> {
match value {
Value::Struct(struct_value) => {
let mut headers = Vec::with_capacity(struct_value.fields.len());
for (key, val) in &struct_value.fields {
let header_value = expect_string_scalar(
val,
"webread: header values must be character vectors or string scalars",
)?;
headers.push((key.clone(), header_value));
}
Ok(headers)
}
Value::Cell(cell) => {
if cell.cols != 2 {
return Err(webread_error(
"webread: HeaderFields cell array must have exactly two columns",
));
}
let mut headers = Vec::with_capacity(cell.rows);
for row in 0..cell.rows {
let name = cell
.get(row, 0)
.map_err(|err| webread_error(format!("webread: {err}")))?;
let value = cell
.get(row, 1)
.map_err(|err| webread_error(format!("webread: {err}")))?;
let header_name = expect_string_scalar(
&name,
"webread: header names must be character vectors or string scalars",
)?;
if header_name.trim().is_empty() {
return Err(webread_error("webread: header names must not be empty"));
}
let header_value = expect_string_scalar(
&value,
"webread: header values must be character vectors or string scalars",
)?;
headers.push((header_name, header_value));
}
Ok(headers)
}
_ => Err(webread_error(
"webread: HeaderFields must be provided as a struct or cell array of name/value pairs",
)),
}
}
fn parse_content_type(value: &Value) -> BuiltinResult<ContentTypeHint> {
let text = expect_string_scalar(
value,
"webread: ContentType must be a character vector or string scalar",
)?;
match text.trim().to_ascii_lowercase().as_str() {
"auto" => Ok(ContentTypeHint::Auto),
"json" => Ok(ContentTypeHint::Json),
"text" | "char" | "string" => Ok(ContentTypeHint::Text),
"binary" | "octet-stream" | "raw" => Ok(ContentTypeHint::Binary),
other => Err(webread_error(format!(
"webread: unsupported ContentType '{}'; use 'auto', 'json', 'text', or 'binary'",
other
))),
}
}
fn parse_timeout(value: &Value) -> BuiltinResult<Duration> {
let seconds = numeric_scalar(value, "webread: Timeout must be a finite, positive scalar")?;
if !seconds.is_finite() || seconds <= 0.0 {
return Err(webread_error(
"webread: Timeout must be a finite, positive scalar",
));
}
Ok(Duration::from_secs_f64(seconds))
}
fn parse_request_method(value: &Value) -> BuiltinResult<HttpMethod> {
let text = expect_string_scalar(
value,
"webread: RequestMethod must be a character vector or string scalar",
)?;
let lower = text.trim().to_ascii_lowercase();
match lower.as_str() {
"get" | "auto" => Ok(HttpMethod::Get),
other => Err(webread_error(format!(
"webread: RequestMethod '{}' is not supported; expected 'auto' or 'get'",
other
))),
}
}
fn numeric_scalar(value: &Value, context: &str) -> BuiltinResult<f64> {
match value {
Value::Num(n) => Ok(*n),
Value::Int(i) => Ok(i.to_f64()),
Value::Tensor(tensor) => {
if tensor.data.len() == 1 {
Ok(tensor.data[0])
} else {
Err(webread_error(context))
}
}
_ => Err(webread_error(context)),
}
}
fn expect_string_scalar(value: &Value, context: &str) -> BuiltinResult<String> {
match value {
Value::String(s) => Ok(s.clone()),
Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
_ => Err(webread_error(context)),
}
}
fn value_to_query_string(value: &Value, name: &str) -> BuiltinResult<String> {
match value {
Value::String(s) => Ok(s.clone()),
Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
Value::Num(n) => Ok(format!("{}", n)),
Value::Int(i) => Ok(i.to_i64().to_string()),
Value::Bool(b) => Ok(if *b { "true".into() } else { "false".into() }),
Value::Tensor(tensor) => {
if tensor.data.len() == 1 {
Ok(format!("{}", tensor.data[0]))
} else {
Err(webread_error(format!(
"webread: query parameter '{}' must be scalar",
name
)))
}
}
Value::LogicalArray(array) => {
if array.len() == 1 {
Ok(if array.data[0] != 0 {
"true".into()
} else {
"false".into()
})
} else {
Err(webread_error(format!(
"webread: query parameter '{}' must be scalar",
name
)))
}
}
_ => Err(webread_error(format!(
"webread: unsupported value type for query parameter '{}'",
name
))),
}
}
#[derive(Clone, Copy, Debug)]
enum ContentTypeHint {
Auto,
Text,
Json,
Binary,
}
#[derive(Clone, Copy, Debug)]
enum ResolvedContentType {
Text,
Json,
Binary,
}
#[derive(Clone, Debug)]
struct WebReadOptions {
content_type: ContentTypeHint,
timeout: Duration,
headers: Vec<(String, String)>,
user_agent: Option<String>,
username: Option<String>,
password: Option<String>,
method: HttpMethod,
}
impl Default for WebReadOptions {
fn default() -> Self {
Self {
content_type: ContentTypeHint::Auto,
timeout: Duration::from_secs_f64(DEFAULT_TIMEOUT_SECONDS),
headers: Vec::new(),
user_agent: None,
username: None,
password: None,
method: HttpMethod::Get,
}
}
}
impl WebReadOptions {
fn resolve_content_type(&self, header: Option<&str>) -> ResolvedContentType {
match self.content_type {
ContentTypeHint::Json => ResolvedContentType::Json,
ContentTypeHint::Text => ResolvedContentType::Text,
ContentTypeHint::Binary => ResolvedContentType::Binary,
ContentTypeHint::Auto => infer_content_type(header),
}
}
}
fn infer_content_type(header: Option<&str>) -> ResolvedContentType {
if let Some(raw) = header {
let mime = raw
.split(';')
.next()
.map(|part| part.trim().to_ascii_lowercase())
.unwrap_or_default();
if mime == "application/json" || mime == "text/json" || mime.ends_with("+json") {
ResolvedContentType::Json
} else if mime.starts_with("text/")
|| mime == "application/xml"
|| mime.ends_with("+xml")
|| mime == "application/xhtml+xml"
|| mime == "application/javascript"
|| mime == "application/x-www-form-urlencoded"
{
ResolvedContentType::Text
} else {
ResolvedContentType::Binary
}
} else {
ResolvedContentType::Text
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc;
use std::thread;
fn error_message(err: RuntimeError) -> String {
err.message().to_string()
}
fn run_webread(url: Value, args: Vec<Value>) -> BuiltinResult<Value> {
futures::executor::block_on(webread_builtin(url, args))
}
fn spawn_server<F>(handler: F) -> String
where
F: FnOnce(TcpStream) + Send + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
let addr = listener.local_addr().unwrap();
thread::spawn(move || {
if let Ok((stream, _)) = listener.accept() {
handler(stream);
}
});
format!("http://{}", addr)
}
fn respond_with(mut stream: TcpStream, content_type: &str, body: &[u8]) {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n",
body.len(),
content_type
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.write_all(body);
}
fn read_request_headers(stream: &mut TcpStream) -> String {
let mut buffer = Vec::new();
let mut chunk = [0u8; 256];
while let Ok(read) = stream.read(&mut chunk) {
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
if buffer.len() > 16 * 1024 {
break;
}
}
String::from_utf8_lossy(&buffer).to_string()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn webread_fetches_json_response() {
let url = spawn_server(|mut stream| {
let mut buffer = [0u8; 1024];
let _ = stream.read(&mut buffer);
respond_with(
stream,
"application/json",
br#"{"message":"hello","value":42}"#,
);
});
let result = run_webread(Value::from(url), vec![]).expect("webread JSON response");
match result {
Value::Struct(struct_value) => {
let message = struct_value.fields.get("message").expect("message field");
let value = struct_value.fields.get("value").expect("value field");
match message {
Value::CharArray(ca) => {
let text: String = ca.data.iter().collect();
assert_eq!(text, "hello");
}
other => panic!("expected char array, got {other:?}"),
}
match value {
Value::Num(n) => assert_eq!(*n, 42.0),
other => panic!("expected numeric value, got {other:?}"),
}
}
other => panic!("expected struct, got {other:?}"),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn webread_fetches_text_response() {
let url = spawn_server(|mut stream| {
let mut buffer = [0u8; 512];
let _ = stream.read(&mut buffer);
respond_with(stream, "text/plain; charset=utf-8", b"RunMat webread test");
});
let result = run_webread(Value::from(url), vec![]).expect("webread text response");
match result {
Value::CharArray(ca) => {
let text: String = ca.data.iter().collect();
assert_eq!(text, "RunMat webread test");
}
other => panic!("expected char array, got {other:?}"),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn webread_fetches_binary_payload() {
let payload = [1u8, 2, 3, 254, 255];
let url = spawn_server(move |mut stream| {
let mut buffer = [0u8; 512];
let _ = stream.read(&mut buffer);
respond_with(stream, "application/octet-stream", &payload);
});
let args = vec![Value::from("ContentType"), Value::from("binary")];
let result = run_webread(Value::from(url), args).expect("webread binary response");
match result {
Value::Tensor(tensor) => {
assert_eq!(tensor.shape, vec![1, 5]);
let bytes: Vec<u8> = tensor.data.iter().map(|v| *v as u8).collect();
assert_eq!(bytes, payload);
}
other => panic!("expected tensor, got {other:?}"),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn webread_appends_query_parameters() {
let (tx, rx) = mpsc::channel();
let url = spawn_server(move |mut stream| {
let request = read_request_headers(&mut stream);
let _ = tx.send(request);
respond_with(stream, "application/json", br#"{"ok":true}"#);
});
let args = vec![
Value::from("count"),
Value::Num(42.0),
Value::from("ContentType"),
Value::from("json"),
];
let result = run_webread(Value::from(url.clone()), args).expect("webread query");
match result {
Value::Struct(struct_value) => {
assert!(struct_value.fields.contains_key("ok"));
}
other => panic!("expected struct result, got {other:?}"),
}
let request = rx.recv().expect("request log");
assert!(
request.starts_with("GET /"),
"unexpected request line: {request}"
);
assert!(
request.contains("count=42"),
"query parameters missing: {request}"
);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn webread_struct_argument_supports_options_and_query() {
let (tx, rx) = mpsc::channel();
let url = spawn_server(move |mut stream| {
let request = read_request_headers(&mut stream);
let _ = tx.send(request);
respond_with(stream, "application/json", br#"{"value":123}"#);
});
let mut fields = StructValue::new();
fields
.fields
.insert("ContentType".to_string(), Value::from("json"));
fields.fields.insert("limit".to_string(), Value::Num(5.0));
let result = run_webread(Value::from(url.clone()), vec![Value::Struct(fields)])
.expect("webread struct arg");
let request = rx.recv().expect("request log");
assert!(
request.contains("GET /?limit=5"),
"expected limit query parameter: {request}"
);
match result {
Value::Struct(struct_value) => match struct_value.fields.get("value") {
Some(Value::Num(n)) => assert_eq!(*n, 123.0),
other => panic!("unexpected JSON decode result: {other:?}"),
},
other => panic!("expected struct, got {other:?}"),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn webread_headerfields_struct_applies_custom_headers() {
let (tx, rx) = mpsc::channel();
let url = spawn_server(move |mut stream| {
let request = read_request_headers(&mut stream);
let _ = tx.send(request);
respond_with(stream, "application/json", br#"{"ok":true}"#);
});
let mut headers = StructValue::new();
headers
.fields
.insert("X-Test".to_string(), Value::from("RunMat"));
let args = vec![
Value::from("HeaderFields"),
Value::Struct(headers),
Value::from("ContentType"),
Value::from("json"),
];
let result = run_webread(Value::from(url), args).expect("webread header fields");
assert!(matches!(result, Value::Struct(_)));
let request = rx.recv().expect("request log");
assert!(
request.to_ascii_lowercase().contains("x-test: runmat"),
"custom header missing: {request}"
);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn webread_queryparameters_option_struct() {
let (tx, rx) = mpsc::channel();
let url = spawn_server(move |mut stream| {
let request = read_request_headers(&mut stream);
let _ = tx.send(request);
respond_with(stream, "application/json", br#"{"ok":true}"#);
});
let mut params = StructValue::new();
params.fields.insert("page".to_string(), Value::Num(2.0));
let args = vec![
Value::from("QueryParameters"),
Value::Struct(params),
Value::from("ContentType"),
Value::from("json"),
];
let result = run_webread(Value::from(url.clone()), args).expect("webread query parameters");
assert!(matches!(result, Value::Struct(_)));
let request = rx.recv().expect("request log");
assert!(
request.contains("page=2"),
"query parameter missing: {request}"
);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn webread_errors_on_missing_name_value_pair() {
let err = run_webread(
Value::from("https://example.com"),
vec![Value::from("Timeout")],
)
.expect_err("expected missing value error");
let err = error_message(err);
assert!(
err.contains("missing value"),
"unexpected error message: {err}"
);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn webread_rejects_non_positive_timeout() {
let args = vec![Value::from("Timeout"), Value::Num(0.0)];
let err = run_webread(Value::from("https://example.com"), args).expect_err("timeout error");
let err = error_message(err);
assert!(
err.contains("Timeout must be a finite, positive scalar"),
"unexpected error message: {err}"
);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn webread_rejects_password_without_username() {
let args = vec![Value::from("Password"), Value::from("secret")];
let err = run_webread(Value::from("https://example.com"), args).expect_err("auth error");
let err = error_message(err);
assert!(
err.contains("Password requires a Username"),
"unexpected error message: {err}"
);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn webread_rejects_unsupported_content_type() {
let args = vec![Value::from("ContentType"), Value::from("table")];
let err = run_webread(Value::from("https://example.com"), args).expect_err("format error");
let err = error_message(err);
assert!(
err.contains("unsupported ContentType"),
"unexpected error message: {err}"
);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn webread_rejects_invalid_headerfields_shape() {
let cell = crate::make_cell(
vec![Value::from("A"), Value::from("B"), Value::from("C")],
1,
3,
)
.expect("make cell");
let args = vec![Value::from("HeaderFields"), cell];
let err = run_webread(Value::from("https://example.com"), args).expect_err("header error");
let err = error_message(err);
assert!(
err.contains("HeaderFields cell array must have exactly two columns"),
"unexpected error message: {err}"
);
}
}