Skip to main content

runmat_runtime/builtins/io/http/
webread.rs

1//! MATLAB-compatible `webread` builtin for HTTP/HTTPS downloads.
2
3use std::collections::VecDeque;
4use std::time::Duration;
5
6use base64::engine::general_purpose::STANDARD as BASE64_ENGINE;
7use base64::Engine;
8use runmat_builtins::{CellArray, CharArray, StructValue, Tensor, Value};
9use runmat_macros::runtime_builtin;
10use url::Url;
11
12use super::transport::{
13    self, decode_body_as_text, header_value, HttpMethod, HttpRequest, HEADER_CONTENT_TYPE,
14};
15use crate::builtins::common::spec::{
16    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
17    ReductionNaN, ResidencyPolicy, ShapeRequirements,
18};
19use crate::builtins::io::json::jsondecode::decode_json_text;
20use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
21
22const DEFAULT_TIMEOUT_SECONDS: f64 = 60.0;
23const DEFAULT_USER_AGENT: &str = "RunMat webread/0.0";
24
25#[allow(clippy::too_many_lines)]
26#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::http::webread")]
27pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
28    name: "webread",
29    op_kind: GpuOpKind::Custom("http-get"),
30    supported_precisions: &[],
31    broadcast: BroadcastSemantics::None,
32    provider_hooks: &[],
33    constant_strategy: ConstantStrategy::InlineLiteral,
34    residency: ResidencyPolicy::GatherImmediately,
35    nan_mode: ReductionNaN::Include,
36    two_pass_threshold: None,
37    workgroup_size: None,
38    accepts_nan_mode: false,
39    notes: "HTTP requests always execute on the CPU; gpuArray inputs are gathered eagerly.",
40};
41
42fn webread_error(message: impl Into<String>) -> RuntimeError {
43    build_runtime_error(message).with_builtin("webread").build()
44}
45
46fn remap_webread_flow<F>(err: RuntimeError, message: F) -> RuntimeError
47where
48    F: FnOnce(&RuntimeError) -> String,
49{
50    build_runtime_error(message(&err))
51        .with_builtin("webread")
52        .with_source(err)
53        .build()
54}
55
56fn webread_flow_with_context(err: RuntimeError) -> RuntimeError {
57    remap_webread_flow(err, |err| format!("webread: {}", err.message()))
58}
59
60#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::http::webread")]
61pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
62    name: "webread",
63    shape: ShapeRequirements::Any,
64    constant_strategy: ConstantStrategy::InlineLiteral,
65    elementwise: None,
66    reduction: None,
67    emits_nan: false,
68    notes: "webread performs network I/O and terminates fusion graphs.",
69};
70
71#[runtime_builtin(
72    name = "webread",
73    category = "io/http",
74    summary = "Download web content (JSON, text, or binary) over HTTP/HTTPS.",
75    keywords = "webread,http get,rest client,json,api",
76    accel = "sink",
77    type_resolver(crate::builtins::io::type_resolvers::webread_type),
78    builtin_path = "crate::builtins::io::http::webread"
79)]
80async fn webread_builtin(url: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
81    let gathered_url = gather_if_needed_async(&url)
82        .await
83        .map_err(webread_flow_with_context)?;
84    let gathered_args = gather_arguments(rest).await?;
85    let url_text = expect_string_scalar(
86        &gathered_url,
87        "webread: URL must be a character vector or string scalar",
88    )?;
89    if url_text.trim().is_empty() {
90        return Err(webread_error("webread: URL must not be empty"));
91    }
92    let (options, query_params) = parse_arguments(gathered_args)?;
93    execute_request(&url_text, options, &query_params)
94}
95
96async fn gather_arguments(values: Vec<Value>) -> BuiltinResult<Vec<Value>> {
97    let mut out = Vec::with_capacity(values.len());
98    for value in values {
99        out.push(
100            gather_if_needed_async(&value)
101                .await
102                .map_err(webread_flow_with_context)?,
103        );
104    }
105    Ok(out)
106}
107
108fn parse_arguments(args: Vec<Value>) -> BuiltinResult<(WebReadOptions, Vec<(String, String)>)> {
109    let mut queue: VecDeque<Value> = args.into();
110    let mut options = WebReadOptions::default();
111    let mut query_params = Vec::new();
112
113    if matches!(queue.front(), Some(Value::Struct(_))) {
114        if let Some(Value::Struct(struct_value)) = queue.pop_front() {
115            process_struct_fields(&struct_value, &mut options, &mut query_params)?;
116        }
117    } else if matches!(queue.front(), Some(Value::Cell(_))) {
118        if let Some(Value::Cell(cell)) = queue.pop_front() {
119            append_query_from_cell(&cell, &mut query_params)?
120        }
121    }
122
123    while let Some(name_value) = queue.pop_front() {
124        let name = expect_string_scalar(
125            &name_value,
126            "webread: parameter names must be character vectors or string scalars",
127        )?;
128        let value = queue
129            .pop_front()
130            .ok_or_else(|| webread_error("webread: missing value for name-value argument"))?;
131        process_name_value_pair(&name, &value, &mut options, &mut query_params)?;
132    }
133
134    Ok((options, query_params))
135}
136
137fn process_struct_fields(
138    struct_value: &StructValue,
139    options: &mut WebReadOptions,
140    query_params: &mut Vec<(String, String)>,
141) -> BuiltinResult<()> {
142    for (key, value) in &struct_value.fields {
143        process_name_value_pair(key, value, options, query_params)?;
144    }
145    Ok(())
146}
147
148fn process_name_value_pair(
149    name: &str,
150    value: &Value,
151    options: &mut WebReadOptions,
152    query_params: &mut Vec<(String, String)>,
153) -> BuiltinResult<()> {
154    let lower = name.to_ascii_lowercase();
155    match lower.as_str() {
156        "contenttype" => {
157            options.content_type = parse_content_type(value)?;
158            Ok(())
159        }
160        "timeout" => {
161            options.timeout = parse_timeout(value)?;
162            Ok(())
163        }
164        "headerfields" => {
165            let headers = parse_header_fields(value)?;
166            options.headers.extend(headers);
167            Ok(())
168        }
169        "useragent" => {
170            options.user_agent = Some(expect_string_scalar(
171                value,
172                "webread: UserAgent must be a character vector or string scalar",
173            )?);
174            Ok(())
175        }
176        "username" => {
177            options.username = Some(expect_string_scalar(
178                value,
179                "webread: Username must be a character vector or string scalar",
180            )?);
181            Ok(())
182        }
183        "password" => {
184            options.password = Some(expect_string_scalar(
185                value,
186                "webread: Password must be a character vector or string scalar",
187            )?);
188            Ok(())
189        }
190        "requestmethod" => {
191            options.method = parse_request_method(value)?;
192            Ok(())
193        }
194        "mediatype" => {
195            // weboptions exposes MediaType for webwrite; accept and ignore for webread.
196            expect_string_scalar(
197                value,
198                "webread: MediaType must be a character vector or string scalar",
199            )?;
200            Ok(())
201        }
202        "queryparameters" => append_query_from_value(value, query_params),
203        _ => {
204            let param_value = value_to_query_string(value, name)?;
205            query_params.push((name.to_string(), param_value));
206            Ok(())
207        }
208    }
209}
210
211fn append_query_from_value(
212    value: &Value,
213    query_params: &mut Vec<(String, String)>,
214) -> BuiltinResult<()> {
215    match value {
216        Value::Struct(struct_value) => {
217            for (key, val) in &struct_value.fields {
218                let text = value_to_query_string(val, key)?;
219                query_params.push((key.clone(), text));
220            }
221            Ok(())
222        }
223        Value::Cell(cell) => append_query_from_cell(cell, query_params),
224        _ => Err(webread_error(
225            "webread: QueryParameters must be a struct or cell array",
226        )),
227    }
228}
229
230fn append_query_from_cell(
231    cell: &CellArray,
232    query_params: &mut Vec<(String, String)>,
233) -> BuiltinResult<()> {
234    if cell.cols != 2 {
235        return Err(webread_error(
236            "webread: cell array of query parameters must have two columns",
237        ));
238    }
239    for row in 0..cell.rows {
240        let name_value = cell
241            .get(row, 0)
242            .map_err(|err| webread_error(format!("webread: {err}")))?;
243        let value_value = cell
244            .get(row, 1)
245            .map_err(|err| webread_error(format!("webread: {err}")))?;
246        let name = expect_string_scalar(
247            &name_value,
248            "webread: query parameter names must be text scalars",
249        )?;
250        let text = value_to_query_string(&value_value, &name)?;
251        query_params.push((name, text));
252    }
253    Ok(())
254}
255
256fn execute_request(
257    url_text: &str,
258    options: WebReadOptions,
259    query_params: &[(String, String)],
260) -> BuiltinResult<Value> {
261    let username_present = options
262        .username
263        .as_ref()
264        .map(|s| !s.is_empty())
265        .unwrap_or(false);
266    let password_present = options
267        .password
268        .as_ref()
269        .map(|s| !s.is_empty())
270        .unwrap_or(false);
271    if password_present && !username_present {
272        return Err(webread_error(
273            "webread: Password requires a Username option",
274        ));
275    }
276
277    let mut url = Url::parse(url_text).map_err(|err| {
278        build_runtime_error(format!("webread: invalid URL '{url_text}': {err}"))
279            .with_builtin("webread")
280            .with_source(err)
281            .build()
282    })?;
283    if !query_params.is_empty() {
284        {
285            let mut pairs = url.query_pairs_mut();
286            for (name, value) in query_params {
287                pairs.append_pair(name, value);
288            }
289        }
290    }
291    let user_agent = options
292        .user_agent
293        .as_deref()
294        .filter(|ua| !ua.trim().is_empty())
295        .unwrap_or(DEFAULT_USER_AGENT)
296        .to_string();
297
298    let mut headers = options.headers.clone();
299    let has_auth_header = headers
300        .iter()
301        .any(|(name, _)| name.eq_ignore_ascii_case("authorization"));
302    if !has_auth_header {
303        if let Some(username) = options.username.as_ref().filter(|s| !s.is_empty()) {
304            let password = options.password.clone().unwrap_or_default();
305            let token = BASE64_ENGINE.encode(format!("{username}:{password}"));
306            headers.push(("Authorization".to_string(), format!("Basic {token}")));
307        }
308    }
309
310    let request = HttpRequest {
311        url,
312        method: HttpMethod::Get,
313        headers,
314        body: None,
315        timeout: options.timeout,
316        user_agent,
317    };
318
319    let response = transport::send_request(&request).map_err(|err| {
320        build_runtime_error(err.message_with_prefix("webread"))
321            .with_builtin("webread")
322            .with_source(err)
323            .build()
324    })?;
325
326    let header_content_type =
327        header_value(&response.headers, HEADER_CONTENT_TYPE).map(|value| value.to_string());
328    let resolved = options.resolve_content_type(header_content_type.as_deref());
329
330    match resolved {
331        ResolvedContentType::Json => {
332            let body = decode_body_as_text(&response.body, header_content_type.as_deref());
333            let value = decode_json_text(&body).map_err(map_json_error)?;
334            Ok(value)
335        }
336        ResolvedContentType::Text => {
337            let text = decode_body_as_text(&response.body, header_content_type.as_deref());
338            let array = CharArray::new_row(&text);
339            Ok(Value::CharArray(array))
340        }
341        ResolvedContentType::Binary => {
342            let data: Vec<f64> = response.body.iter().map(|b| f64::from(*b)).collect();
343            let cols = response.body.len();
344            let tensor = Tensor::new(data, vec![1, cols])
345                .map_err(|err| webread_error(format!("webread: {err}")))?;
346            Ok(Value::Tensor(tensor))
347        }
348    }
349}
350
351fn map_json_error(err: RuntimeError) -> RuntimeError {
352    let message = if let Some(rest) = err.message().strip_prefix("jsondecode: ") {
353        format!("webread: failed to parse JSON response ({rest})")
354    } else {
355        format!("webread: failed to parse JSON response ({})", err.message())
356    };
357    build_runtime_error(message)
358        .with_builtin("webread")
359        .with_source(err)
360        .build()
361}
362
363fn parse_header_fields(value: &Value) -> BuiltinResult<Vec<(String, String)>> {
364    match value {
365        Value::Struct(struct_value) => {
366            let mut headers = Vec::with_capacity(struct_value.fields.len());
367            for (key, val) in &struct_value.fields {
368                let header_value = expect_string_scalar(
369                    val,
370                    "webread: header values must be character vectors or string scalars",
371                )?;
372                headers.push((key.clone(), header_value));
373            }
374            Ok(headers)
375        }
376        Value::Cell(cell) => {
377            if cell.cols != 2 {
378                return Err(webread_error(
379                    "webread: HeaderFields cell array must have exactly two columns",
380                ));
381            }
382            let mut headers = Vec::with_capacity(cell.rows);
383            for row in 0..cell.rows {
384                let name = cell
385                    .get(row, 0)
386                    .map_err(|err| webread_error(format!("webread: {err}")))?;
387                let value = cell
388                    .get(row, 1)
389                    .map_err(|err| webread_error(format!("webread: {err}")))?;
390                let header_name = expect_string_scalar(
391                    &name,
392                    "webread: header names must be character vectors or string scalars",
393                )?;
394                if header_name.trim().is_empty() {
395                    return Err(webread_error("webread: header names must not be empty"));
396                }
397                let header_value = expect_string_scalar(
398                    &value,
399                    "webread: header values must be character vectors or string scalars",
400                )?;
401                headers.push((header_name, header_value));
402            }
403            Ok(headers)
404        }
405        _ => Err(webread_error(
406            "webread: HeaderFields must be provided as a struct or cell array of name/value pairs",
407        )),
408    }
409}
410
411fn parse_content_type(value: &Value) -> BuiltinResult<ContentTypeHint> {
412    let text = expect_string_scalar(
413        value,
414        "webread: ContentType must be a character vector or string scalar",
415    )?;
416    match text.trim().to_ascii_lowercase().as_str() {
417        "auto" => Ok(ContentTypeHint::Auto),
418        "json" => Ok(ContentTypeHint::Json),
419        "text" | "char" | "string" => Ok(ContentTypeHint::Text),
420        "binary" | "octet-stream" | "raw" => Ok(ContentTypeHint::Binary),
421        other => Err(webread_error(format!(
422            "webread: unsupported ContentType '{}'; use 'auto', 'json', 'text', or 'binary'",
423            other
424        ))),
425    }
426}
427
428fn parse_timeout(value: &Value) -> BuiltinResult<Duration> {
429    let seconds = numeric_scalar(value, "webread: Timeout must be a finite, positive scalar")?;
430    if !seconds.is_finite() || seconds <= 0.0 {
431        return Err(webread_error(
432            "webread: Timeout must be a finite, positive scalar",
433        ));
434    }
435    Ok(Duration::from_secs_f64(seconds))
436}
437
438fn parse_request_method(value: &Value) -> BuiltinResult<HttpMethod> {
439    let text = expect_string_scalar(
440        value,
441        "webread: RequestMethod must be a character vector or string scalar",
442    )?;
443    let lower = text.trim().to_ascii_lowercase();
444    match lower.as_str() {
445        "get" | "auto" => Ok(HttpMethod::Get),
446        other => Err(webread_error(format!(
447            "webread: RequestMethod '{}' is not supported; expected 'auto' or 'get'",
448            other
449        ))),
450    }
451}
452
453fn numeric_scalar(value: &Value, context: &str) -> BuiltinResult<f64> {
454    match value {
455        Value::Num(n) => Ok(*n),
456        Value::Int(i) => Ok(i.to_f64()),
457        Value::Tensor(tensor) => {
458            if tensor.data.len() == 1 {
459                Ok(tensor.data[0])
460            } else {
461                Err(webread_error(context))
462            }
463        }
464        _ => Err(webread_error(context)),
465    }
466}
467
468fn expect_string_scalar(value: &Value, context: &str) -> BuiltinResult<String> {
469    match value {
470        Value::String(s) => Ok(s.clone()),
471        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
472        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
473        _ => Err(webread_error(context)),
474    }
475}
476
477fn value_to_query_string(value: &Value, name: &str) -> BuiltinResult<String> {
478    match value {
479        Value::String(s) => Ok(s.clone()),
480        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
481        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
482        Value::Num(n) => Ok(format!("{}", n)),
483        Value::Int(i) => Ok(i.to_i64().to_string()),
484        Value::Bool(b) => Ok(if *b { "true".into() } else { "false".into() }),
485        Value::Tensor(tensor) => {
486            if tensor.data.len() == 1 {
487                Ok(format!("{}", tensor.data[0]))
488            } else {
489                Err(webread_error(format!(
490                    "webread: query parameter '{}' must be scalar",
491                    name
492                )))
493            }
494        }
495        Value::LogicalArray(array) => {
496            if array.len() == 1 {
497                Ok(if array.data[0] != 0 {
498                    "true".into()
499                } else {
500                    "false".into()
501                })
502            } else {
503                Err(webread_error(format!(
504                    "webread: query parameter '{}' must be scalar",
505                    name
506                )))
507            }
508        }
509        _ => Err(webread_error(format!(
510            "webread: unsupported value type for query parameter '{}'",
511            name
512        ))),
513    }
514}
515
516#[derive(Clone, Copy, Debug)]
517enum ContentTypeHint {
518    Auto,
519    Text,
520    Json,
521    Binary,
522}
523
524#[derive(Clone, Copy, Debug)]
525enum ResolvedContentType {
526    Text,
527    Json,
528    Binary,
529}
530
531#[derive(Clone, Debug)]
532struct WebReadOptions {
533    content_type: ContentTypeHint,
534    timeout: Duration,
535    headers: Vec<(String, String)>,
536    user_agent: Option<String>,
537    username: Option<String>,
538    password: Option<String>,
539    method: HttpMethod,
540}
541
542impl Default for WebReadOptions {
543    fn default() -> Self {
544        Self {
545            content_type: ContentTypeHint::Auto,
546            timeout: Duration::from_secs_f64(DEFAULT_TIMEOUT_SECONDS),
547            headers: Vec::new(),
548            user_agent: None,
549            username: None,
550            password: None,
551            method: HttpMethod::Get,
552        }
553    }
554}
555
556impl WebReadOptions {
557    fn resolve_content_type(&self, header: Option<&str>) -> ResolvedContentType {
558        match self.content_type {
559            ContentTypeHint::Json => ResolvedContentType::Json,
560            ContentTypeHint::Text => ResolvedContentType::Text,
561            ContentTypeHint::Binary => ResolvedContentType::Binary,
562            ContentTypeHint::Auto => infer_content_type(header),
563        }
564    }
565}
566
567fn infer_content_type(header: Option<&str>) -> ResolvedContentType {
568    if let Some(raw) = header {
569        let mime = raw
570            .split(';')
571            .next()
572            .map(|part| part.trim().to_ascii_lowercase())
573            .unwrap_or_default();
574        if mime == "application/json" || mime == "text/json" || mime.ends_with("+json") {
575            ResolvedContentType::Json
576        } else if mime.starts_with("text/")
577            || mime == "application/xml"
578            || mime.ends_with("+xml")
579            || mime == "application/xhtml+xml"
580            || mime == "application/javascript"
581            || mime == "application/x-www-form-urlencoded"
582        {
583            ResolvedContentType::Text
584        } else {
585            ResolvedContentType::Binary
586        }
587    } else {
588        ResolvedContentType::Text
589    }
590}
591
592#[cfg(test)]
593pub(crate) mod tests {
594    use super::*;
595    use std::io::{Read, Write};
596    use std::net::{TcpListener, TcpStream};
597    use std::sync::mpsc;
598    use std::thread;
599
600    fn error_message(err: RuntimeError) -> String {
601        err.message().to_string()
602    }
603
604    fn run_webread(url: Value, args: Vec<Value>) -> BuiltinResult<Value> {
605        futures::executor::block_on(webread_builtin(url, args))
606    }
607
608    fn spawn_server<F>(handler: F) -> String
609    where
610        F: FnOnce(TcpStream) + Send + 'static,
611    {
612        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
613        let addr = listener.local_addr().unwrap();
614        thread::spawn(move || {
615            if let Ok((stream, _)) = listener.accept() {
616                handler(stream);
617            }
618        });
619        format!("http://{}", addr)
620    }
621
622    fn respond_with(mut stream: TcpStream, content_type: &str, body: &[u8]) {
623        let response = format!(
624            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n",
625            body.len(),
626            content_type
627        );
628        let _ = stream.write_all(response.as_bytes());
629        let _ = stream.write_all(body);
630    }
631
632    fn read_request_headers(stream: &mut TcpStream) -> String {
633        let mut buffer = Vec::new();
634        let mut chunk = [0u8; 256];
635        while let Ok(read) = stream.read(&mut chunk) {
636            if read == 0 {
637                break;
638            }
639            buffer.extend_from_slice(&chunk[..read]);
640            if buffer.windows(4).any(|w| w == b"\r\n\r\n") {
641                break;
642            }
643            if buffer.len() > 16 * 1024 {
644                break;
645            }
646        }
647        String::from_utf8_lossy(&buffer).to_string()
648    }
649
650    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
651    #[test]
652    fn webread_fetches_json_response() {
653        let url = spawn_server(|mut stream| {
654            let mut buffer = [0u8; 1024];
655            let _ = stream.read(&mut buffer);
656            respond_with(
657                stream,
658                "application/json",
659                br#"{"message":"hello","value":42}"#,
660            );
661        });
662
663        let result = run_webread(Value::from(url), vec![]).expect("webread JSON response");
664
665        match result {
666            Value::Struct(struct_value) => {
667                let message = struct_value.fields.get("message").expect("message field");
668                let value = struct_value.fields.get("value").expect("value field");
669                match message {
670                    Value::CharArray(ca) => {
671                        let text: String = ca.data.iter().collect();
672                        assert_eq!(text, "hello");
673                    }
674                    other => panic!("expected char array, got {other:?}"),
675                }
676                match value {
677                    Value::Num(n) => assert_eq!(*n, 42.0),
678                    other => panic!("expected numeric value, got {other:?}"),
679                }
680            }
681            other => panic!("expected struct, got {other:?}"),
682        }
683    }
684
685    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
686    #[test]
687    fn webread_fetches_text_response() {
688        let url = spawn_server(|mut stream| {
689            let mut buffer = [0u8; 512];
690            let _ = stream.read(&mut buffer);
691            respond_with(stream, "text/plain; charset=utf-8", b"RunMat webread test");
692        });
693
694        let result = run_webread(Value::from(url), vec![]).expect("webread text response");
695
696        match result {
697            Value::CharArray(ca) => {
698                let text: String = ca.data.iter().collect();
699                assert_eq!(text, "RunMat webread test");
700            }
701            other => panic!("expected char array, got {other:?}"),
702        }
703    }
704
705    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
706    #[test]
707    fn webread_fetches_binary_payload() {
708        let payload = [1u8, 2, 3, 254, 255];
709        let url = spawn_server(move |mut stream| {
710            let mut buffer = [0u8; 512];
711            let _ = stream.read(&mut buffer);
712            respond_with(stream, "application/octet-stream", &payload);
713        });
714
715        let args = vec![Value::from("ContentType"), Value::from("binary")];
716        let result = run_webread(Value::from(url), args).expect("webread binary response");
717
718        match result {
719            Value::Tensor(tensor) => {
720                assert_eq!(tensor.shape, vec![1, 5]);
721                let bytes: Vec<u8> = tensor.data.iter().map(|v| *v as u8).collect();
722                assert_eq!(bytes, payload);
723            }
724            other => panic!("expected tensor, got {other:?}"),
725        }
726    }
727
728    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
729    #[test]
730    fn webread_appends_query_parameters() {
731        let (tx, rx) = mpsc::channel();
732        let url = spawn_server(move |mut stream| {
733            let request = read_request_headers(&mut stream);
734            let _ = tx.send(request);
735            respond_with(stream, "application/json", br#"{"ok":true}"#);
736        });
737
738        let args = vec![
739            Value::from("count"),
740            Value::Num(42.0),
741            Value::from("ContentType"),
742            Value::from("json"),
743        ];
744        let result = run_webread(Value::from(url.clone()), args).expect("webread query");
745        match result {
746            Value::Struct(struct_value) => {
747                assert!(struct_value.fields.contains_key("ok"));
748            }
749            other => panic!("expected struct result, got {other:?}"),
750        }
751        let request = rx.recv().expect("request log");
752        assert!(
753            request.starts_with("GET /"),
754            "unexpected request line: {request}"
755        );
756        assert!(
757            request.contains("count=42"),
758            "query parameters missing: {request}"
759        );
760    }
761
762    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
763    #[test]
764    fn webread_struct_argument_supports_options_and_query() {
765        let (tx, rx) = mpsc::channel();
766        let url = spawn_server(move |mut stream| {
767            let request = read_request_headers(&mut stream);
768            let _ = tx.send(request);
769            respond_with(stream, "application/json", br#"{"value":123}"#);
770        });
771
772        let mut fields = StructValue::new();
773        fields
774            .fields
775            .insert("ContentType".to_string(), Value::from("json"));
776        fields.fields.insert("limit".to_string(), Value::Num(5.0));
777
778        let result = run_webread(Value::from(url.clone()), vec![Value::Struct(fields)])
779            .expect("webread struct arg");
780
781        let request = rx.recv().expect("request log");
782        assert!(
783            request.contains("GET /?limit=5"),
784            "expected limit query parameter: {request}"
785        );
786
787        match result {
788            Value::Struct(struct_value) => match struct_value.fields.get("value") {
789                Some(Value::Num(n)) => assert_eq!(*n, 123.0),
790                other => panic!("unexpected JSON decode result: {other:?}"),
791            },
792            other => panic!("expected struct, got {other:?}"),
793        }
794    }
795
796    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
797    #[test]
798    fn webread_headerfields_struct_applies_custom_headers() {
799        let (tx, rx) = mpsc::channel();
800        let url = spawn_server(move |mut stream| {
801            let request = read_request_headers(&mut stream);
802            let _ = tx.send(request);
803            respond_with(stream, "application/json", br#"{"ok":true}"#);
804        });
805
806        let mut headers = StructValue::new();
807        headers
808            .fields
809            .insert("X-Test".to_string(), Value::from("RunMat"));
810
811        let args = vec![
812            Value::from("HeaderFields"),
813            Value::Struct(headers),
814            Value::from("ContentType"),
815            Value::from("json"),
816        ];
817
818        let result = run_webread(Value::from(url), args).expect("webread header fields");
819        assert!(matches!(result, Value::Struct(_)));
820
821        let request = rx.recv().expect("request log");
822        assert!(
823            request.to_ascii_lowercase().contains("x-test: runmat"),
824            "custom header missing: {request}"
825        );
826    }
827
828    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
829    #[test]
830    fn webread_queryparameters_option_struct() {
831        let (tx, rx) = mpsc::channel();
832        let url = spawn_server(move |mut stream| {
833            let request = read_request_headers(&mut stream);
834            let _ = tx.send(request);
835            respond_with(stream, "application/json", br#"{"ok":true}"#);
836        });
837
838        let mut params = StructValue::new();
839        params.fields.insert("page".to_string(), Value::Num(2.0));
840
841        let args = vec![
842            Value::from("QueryParameters"),
843            Value::Struct(params),
844            Value::from("ContentType"),
845            Value::from("json"),
846        ];
847
848        let result = run_webread(Value::from(url.clone()), args).expect("webread query parameters");
849        assert!(matches!(result, Value::Struct(_)));
850
851        let request = rx.recv().expect("request log");
852        assert!(
853            request.contains("page=2"),
854            "query parameter missing: {request}"
855        );
856    }
857
858    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
859    #[test]
860    fn webread_errors_on_missing_name_value_pair() {
861        let err = run_webread(
862            Value::from("https://example.com"),
863            vec![Value::from("Timeout")],
864        )
865        .expect_err("expected missing value error");
866        let err = error_message(err);
867        assert!(
868            err.contains("missing value"),
869            "unexpected error message: {err}"
870        );
871    }
872
873    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
874    #[test]
875    fn webread_rejects_non_positive_timeout() {
876        let args = vec![Value::from("Timeout"), Value::Num(0.0)];
877        let err = run_webread(Value::from("https://example.com"), args).expect_err("timeout error");
878        let err = error_message(err);
879        assert!(
880            err.contains("Timeout must be a finite, positive scalar"),
881            "unexpected error message: {err}"
882        );
883    }
884
885    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
886    #[test]
887    fn webread_rejects_password_without_username() {
888        let args = vec![Value::from("Password"), Value::from("secret")];
889        let err = run_webread(Value::from("https://example.com"), args).expect_err("auth error");
890        let err = error_message(err);
891        assert!(
892            err.contains("Password requires a Username"),
893            "unexpected error message: {err}"
894        );
895    }
896
897    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
898    #[test]
899    fn webread_rejects_unsupported_content_type() {
900        let args = vec![Value::from("ContentType"), Value::from("table")];
901        let err = run_webread(Value::from("https://example.com"), args).expect_err("format error");
902        let err = error_message(err);
903        assert!(
904            err.contains("unsupported ContentType"),
905            "unexpected error message: {err}"
906        );
907    }
908
909    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
910    #[test]
911    fn webread_rejects_invalid_headerfields_shape() {
912        let cell = crate::make_cell(
913            vec![Value::from("A"), Value::from("B"), Value::from("C")],
914            1,
915            3,
916        )
917        .expect("make cell");
918
919        let args = vec![Value::from("HeaderFields"), cell];
920        let err = run_webread(Value::from("https://example.com"), args).expect_err("header error");
921        let err = error_message(err);
922        assert!(
923            err.contains("HeaderFields cell array must have exactly two columns"),
924            "unexpected error message: {err}"
925        );
926    }
927}