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::{
9    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
10    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
11    CellArray, CharArray, StructValue, Tensor, Value,
12};
13use runmat_macros::runtime_builtin;
14use url::Url;
15
16use super::transport::{
17    self, decode_body_as_text, header_value, HttpMethod, HttpRequest, HEADER_CONTENT_TYPE,
18};
19use crate::builtins::common::spec::{
20    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
21    ReductionNaN, ResidencyPolicy, ShapeRequirements,
22};
23use crate::builtins::io::json::jsondecode::decode_json_text;
24use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
25
26const DEFAULT_TIMEOUT_SECONDS: f64 = 60.0;
27const DEFAULT_USER_AGENT: &str = "RunMat webread/0.0";
28const BUILTIN_NAME: &str = "webread";
29
30const WEBREAD_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
31    name: "data",
32    ty: BuiltinParamType::Any,
33    arity: BuiltinParamArity::Required,
34    default: None,
35    description: "Downloaded payload decoded as JSON, text, or binary tensor.",
36}];
37const WEBREAD_INPUTS_URL: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
38    name: "url",
39    ty: BuiltinParamType::StringScalar,
40    arity: BuiltinParamArity::Required,
41    default: None,
42    description: "HTTP/HTTPS URL to fetch.",
43}];
44const WEBREAD_INPUTS_URL_OPTIONS: [BuiltinParamDescriptor; 2] = [
45    BuiltinParamDescriptor {
46        name: "url",
47        ty: BuiltinParamType::StringScalar,
48        arity: BuiltinParamArity::Required,
49        default: None,
50        description: "HTTP/HTTPS URL to fetch.",
51    },
52    BuiltinParamDescriptor {
53        name: "optionsStruct",
54        ty: BuiltinParamType::Any,
55        arity: BuiltinParamArity::Required,
56        default: None,
57        description: "weboptions struct or option struct literal.",
58    },
59];
60const WEBREAD_INPUTS_URL_QUERY_CELL: [BuiltinParamDescriptor; 2] = [
61    BuiltinParamDescriptor {
62        name: "url",
63        ty: BuiltinParamType::StringScalar,
64        arity: BuiltinParamArity::Required,
65        default: None,
66        description: "HTTP/HTTPS URL to fetch.",
67    },
68    BuiltinParamDescriptor {
69        name: "queryParameters",
70        ty: BuiltinParamType::Any,
71        arity: BuiltinParamArity::Required,
72        default: None,
73        description: "Two-column cell array of query parameter names and values.",
74    },
75];
76const WEBREAD_INPUTS_URL_NAME_VALUE: [BuiltinParamDescriptor; 3] = [
77    BuiltinParamDescriptor {
78        name: "url",
79        ty: BuiltinParamType::StringScalar,
80        arity: BuiltinParamArity::Required,
81        default: None,
82        description: "HTTP/HTTPS URL to fetch.",
83    },
84    BuiltinParamDescriptor {
85        name: "name",
86        ty: BuiltinParamType::StringScalar,
87        arity: BuiltinParamArity::Variadic,
88        default: None,
89        description: "Option or query parameter name.",
90    },
91    BuiltinParamDescriptor {
92        name: "value",
93        ty: BuiltinParamType::Any,
94        arity: BuiltinParamArity::Variadic,
95        default: None,
96        description: "Option or query parameter value.",
97    },
98];
99const WEBREAD_INPUTS_URL_OPTIONS_NAME_VALUE: [BuiltinParamDescriptor; 4] = [
100    BuiltinParamDescriptor {
101        name: "url",
102        ty: BuiltinParamType::StringScalar,
103        arity: BuiltinParamArity::Required,
104        default: None,
105        description: "HTTP/HTTPS URL to fetch.",
106    },
107    BuiltinParamDescriptor {
108        name: "optionsStruct",
109        ty: BuiltinParamType::Any,
110        arity: BuiltinParamArity::Required,
111        default: None,
112        description: "weboptions struct or option struct literal.",
113    },
114    BuiltinParamDescriptor {
115        name: "name",
116        ty: BuiltinParamType::StringScalar,
117        arity: BuiltinParamArity::Variadic,
118        default: None,
119        description: "Option or query parameter name.",
120    },
121    BuiltinParamDescriptor {
122        name: "value",
123        ty: BuiltinParamType::Any,
124        arity: BuiltinParamArity::Variadic,
125        default: None,
126        description: "Option or query parameter value.",
127    },
128];
129
130const WEBREAD_SIGNATURES: [BuiltinSignatureDescriptor; 6] = [
131    BuiltinSignatureDescriptor {
132        label: "data = webread(url)",
133        inputs: &WEBREAD_INPUTS_URL,
134        outputs: &WEBREAD_OUTPUT,
135    },
136    BuiltinSignatureDescriptor {
137        label: "data = webread(url, optionsStruct)",
138        inputs: &WEBREAD_INPUTS_URL_OPTIONS,
139        outputs: &WEBREAD_OUTPUT,
140    },
141    BuiltinSignatureDescriptor {
142        label: "data = webread(url, queryParameters)",
143        inputs: &WEBREAD_INPUTS_URL_QUERY_CELL,
144        outputs: &WEBREAD_OUTPUT,
145    },
146    BuiltinSignatureDescriptor {
147        label: "data = webread(url, name, value, ...)",
148        inputs: &WEBREAD_INPUTS_URL_NAME_VALUE,
149        outputs: &WEBREAD_OUTPUT,
150    },
151    BuiltinSignatureDescriptor {
152        label: "data = webread(url, optionsStruct, name, value, ...)",
153        inputs: &WEBREAD_INPUTS_URL_OPTIONS_NAME_VALUE,
154        outputs: &WEBREAD_OUTPUT,
155    },
156    BuiltinSignatureDescriptor {
157        label: "data = webread(url, queryParameters, name, value, ...)",
158        inputs: &WEBREAD_INPUTS_URL_OPTIONS_NAME_VALUE,
159        outputs: &WEBREAD_OUTPUT,
160    },
161];
162
163const WEBREAD_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
164    code: "RM.WEBREAD.INVALID_ARGUMENT",
165    identifier: Some("RunMat:webread:InvalidArgument"),
166    when: "Argument type/shape does not match webread call contract.",
167    message: "webread: invalid argument",
168};
169const WEBREAD_ERROR_INVALID_URL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
170    code: "RM.WEBREAD.INVALID_URL",
171    identifier: Some("RunMat:webread:InvalidUrl"),
172    when: "URL input is empty or invalid.",
173    message: "webread: invalid URL",
174};
175const WEBREAD_ERROR_MISSING_OPTION_VALUE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
176    code: "RM.WEBREAD.MISSING_OPTION_VALUE",
177    identifier: Some("RunMat:webread:MissingOptionValue"),
178    when: "A name-value key has no corresponding value.",
179    message: "webread: missing option value",
180};
181const WEBREAD_ERROR_INVALID_OPTION_VALUE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
182    code: "RM.WEBREAD.INVALID_OPTION_VALUE",
183    identifier: Some("RunMat:webread:InvalidOptionValue"),
184    when: "An option value fails validation.",
185    message: "webread: invalid option value",
186};
187const WEBREAD_ERROR_INVALID_CREDENTIALS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
188    code: "RM.WEBREAD.INVALID_CREDENTIALS",
189    identifier: Some("RunMat:webread:InvalidCredentials"),
190    when: "Password is provided without username.",
191    message: "webread: invalid credentials",
192};
193const WEBREAD_ERROR_TRANSPORT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
194    code: "RM.WEBREAD.TRANSPORT",
195    identifier: Some("RunMat:webread:Transport"),
196    when: "HTTP transport fails.",
197    message: "webread: transport failure",
198};
199const WEBREAD_ERROR_RESPONSE_JSON: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
200    code: "RM.WEBREAD.RESPONSE_JSON",
201    identifier: Some("RunMat:webread:ResponseJson"),
202    when: "Response body cannot be decoded as JSON.",
203    message: "webread: failed to parse JSON response",
204};
205const WEBREAD_ERROR_OUTPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
206    code: "RM.WEBREAD.OUTPUT",
207    identifier: Some("RunMat:webread:Output"),
208    when: "Output payload cannot be materialized.",
209    message: "webread: output materialization failure",
210};
211const WEBREAD_ERROR_FLOW: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
212    code: "RM.WEBREAD.FLOW",
213    identifier: Some("RunMat:webread:Flow"),
214    when: "Nested flow fails while gathering inputs.",
215    message: "webread: flow failure",
216};
217
218const WEBREAD_ERRORS: [BuiltinErrorDescriptor; 9] = [
219    WEBREAD_ERROR_INVALID_ARGUMENT,
220    WEBREAD_ERROR_INVALID_URL,
221    WEBREAD_ERROR_MISSING_OPTION_VALUE,
222    WEBREAD_ERROR_INVALID_OPTION_VALUE,
223    WEBREAD_ERROR_INVALID_CREDENTIALS,
224    WEBREAD_ERROR_TRANSPORT,
225    WEBREAD_ERROR_RESPONSE_JSON,
226    WEBREAD_ERROR_OUTPUT,
227    WEBREAD_ERROR_FLOW,
228];
229
230pub const WEBREAD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
231    signatures: &WEBREAD_SIGNATURES,
232    output_mode: BuiltinOutputMode::Fixed,
233    completion_policy: BuiltinCompletionPolicy::Public,
234    errors: &WEBREAD_ERRORS,
235};
236
237#[allow(clippy::too_many_lines)]
238#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::http::webread")]
239pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
240    name: "webread",
241    op_kind: GpuOpKind::Custom("http-get"),
242    supported_precisions: &[],
243    broadcast: BroadcastSemantics::None,
244    provider_hooks: &[],
245    constant_strategy: ConstantStrategy::InlineLiteral,
246    residency: ResidencyPolicy::GatherImmediately,
247    nan_mode: ReductionNaN::Include,
248    two_pass_threshold: None,
249    workgroup_size: None,
250    accepts_nan_mode: false,
251    notes: "HTTP requests always execute on the CPU; gpuArray inputs are gathered eagerly.",
252};
253
254fn webread_error(message: impl Into<String>) -> RuntimeError {
255    webread_error_with(&WEBREAD_ERROR_INVALID_ARGUMENT, message)
256}
257
258fn webread_error_with(
259    error: &'static BuiltinErrorDescriptor,
260    message: impl Into<String>,
261) -> RuntimeError {
262    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
263    if let Some(identifier) = error.identifier {
264        builder = builder.with_identifier(identifier);
265    }
266    builder.build()
267}
268
269fn webread_error_with_source<E>(
270    error: &'static BuiltinErrorDescriptor,
271    message: impl Into<String>,
272    source: E,
273) -> RuntimeError
274where
275    E: std::error::Error + Send + Sync + 'static,
276{
277    let mut builder = build_runtime_error(message)
278        .with_builtin(BUILTIN_NAME)
279        .with_source(source);
280    if let Some(identifier) = error.identifier {
281        builder = builder.with_identifier(identifier);
282    }
283    builder.build()
284}
285
286fn remap_webread_flow<F>(
287    error: &'static BuiltinErrorDescriptor,
288    err: RuntimeError,
289    message: F,
290) -> RuntimeError
291where
292    F: FnOnce(&RuntimeError) -> String,
293{
294    let mut builder = build_runtime_error(message(&err))
295        .with_builtin(BUILTIN_NAME)
296        .with_source(err);
297    if let Some(identifier) = error.identifier {
298        builder = builder.with_identifier(identifier);
299    }
300    builder.build()
301}
302
303fn webread_flow_with_context(err: RuntimeError) -> RuntimeError {
304    remap_webread_flow(&WEBREAD_ERROR_FLOW, err, |err| {
305        format!("webread: {}", err.message())
306    })
307}
308
309#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::http::webread")]
310pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
311    name: "webread",
312    shape: ShapeRequirements::Any,
313    constant_strategy: ConstantStrategy::InlineLiteral,
314    elementwise: None,
315    reduction: None,
316    emits_nan: false,
317    notes: "webread performs network I/O and terminates fusion graphs.",
318};
319
320#[runtime_builtin(
321    name = "webread",
322    category = "io/http",
323    summary = "Read web content over HTTP/HTTPS with optional request options.",
324    keywords = "webread,http get,rest client,json,api",
325    accel = "sink",
326    type_resolver(crate::builtins::io::type_resolvers::webread_type),
327    descriptor(crate::builtins::io::http::webread::WEBREAD_DESCRIPTOR),
328    builtin_path = "crate::builtins::io::http::webread"
329)]
330async fn webread_builtin(url: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
331    let gathered_url = gather_if_needed_async(&url)
332        .await
333        .map_err(webread_flow_with_context)?;
334    let gathered_args = gather_arguments(rest).await?;
335    let url_text = expect_string_scalar(
336        &gathered_url,
337        "webread: URL must be a character vector or string scalar",
338    )?;
339    if url_text.trim().is_empty() {
340        return Err(webread_error_with(
341            &WEBREAD_ERROR_INVALID_URL,
342            "webread: URL must not be empty",
343        ));
344    }
345    let (options, query_params) = parse_arguments(gathered_args)?;
346    execute_request(&url_text, options, &query_params)
347}
348
349async fn gather_arguments(values: Vec<Value>) -> BuiltinResult<Vec<Value>> {
350    let mut out = Vec::with_capacity(values.len());
351    for value in values {
352        out.push(
353            gather_if_needed_async(&value)
354                .await
355                .map_err(webread_flow_with_context)?,
356        );
357    }
358    Ok(out)
359}
360
361fn parse_arguments(args: Vec<Value>) -> BuiltinResult<(WebReadOptions, Vec<(String, String)>)> {
362    let mut queue: VecDeque<Value> = args.into();
363    let mut options = WebReadOptions::default();
364    let mut query_params = Vec::new();
365
366    if matches!(queue.front(), Some(Value::Struct(_))) {
367        if let Some(Value::Struct(struct_value)) = queue.pop_front() {
368            process_struct_fields(&struct_value, &mut options, &mut query_params)?;
369        }
370    } else if matches!(queue.front(), Some(Value::Cell(_))) {
371        if let Some(Value::Cell(cell)) = queue.pop_front() {
372            append_query_from_cell(&cell, &mut query_params)?
373        }
374    }
375
376    while let Some(name_value) = queue.pop_front() {
377        let name = expect_string_scalar(
378            &name_value,
379            "webread: parameter names must be character vectors or string scalars",
380        )?;
381        let value = queue.pop_front().ok_or_else(|| {
382            webread_error_with(
383                &WEBREAD_ERROR_MISSING_OPTION_VALUE,
384                "webread: missing value for name-value argument",
385            )
386        })?;
387        process_name_value_pair(&name, &value, &mut options, &mut query_params)?;
388    }
389
390    Ok((options, query_params))
391}
392
393fn process_struct_fields(
394    struct_value: &StructValue,
395    options: &mut WebReadOptions,
396    query_params: &mut Vec<(String, String)>,
397) -> BuiltinResult<()> {
398    for (key, value) in &struct_value.fields {
399        process_name_value_pair(key, value, options, query_params)?;
400    }
401    Ok(())
402}
403
404fn process_name_value_pair(
405    name: &str,
406    value: &Value,
407    options: &mut WebReadOptions,
408    query_params: &mut Vec<(String, String)>,
409) -> BuiltinResult<()> {
410    let lower = name.to_ascii_lowercase();
411    match lower.as_str() {
412        "contenttype" => {
413            options.content_type = parse_content_type(value)?;
414            Ok(())
415        }
416        "timeout" => {
417            options.timeout = parse_timeout(value)?;
418            Ok(())
419        }
420        "headerfields" => {
421            let headers = parse_header_fields(value)?;
422            options.headers.extend(headers);
423            Ok(())
424        }
425        "useragent" => {
426            options.user_agent = Some(expect_string_scalar(
427                value,
428                "webread: UserAgent must be a character vector or string scalar",
429            )?);
430            Ok(())
431        }
432        "username" => {
433            options.username = Some(expect_string_scalar(
434                value,
435                "webread: Username must be a character vector or string scalar",
436            )?);
437            Ok(())
438        }
439        "password" => {
440            options.password = Some(expect_string_scalar(
441                value,
442                "webread: Password must be a character vector or string scalar",
443            )?);
444            Ok(())
445        }
446        "requestmethod" => {
447            options.method = parse_request_method(value)?;
448            Ok(())
449        }
450        "mediatype" => {
451            // weboptions exposes MediaType for webwrite; accept and ignore for webread.
452            expect_string_scalar(
453                value,
454                "webread: MediaType must be a character vector or string scalar",
455            )?;
456            Ok(())
457        }
458        "queryparameters" => append_query_from_value(value, query_params),
459        _ => {
460            let param_value = value_to_query_string(value, name)?;
461            query_params.push((name.to_string(), param_value));
462            Ok(())
463        }
464    }
465}
466
467fn append_query_from_value(
468    value: &Value,
469    query_params: &mut Vec<(String, String)>,
470) -> BuiltinResult<()> {
471    match value {
472        Value::Struct(struct_value) => {
473            for (key, val) in &struct_value.fields {
474                let text = value_to_query_string(val, key)?;
475                query_params.push((key.clone(), text));
476            }
477            Ok(())
478        }
479        Value::Cell(cell) => append_query_from_cell(cell, query_params),
480        _ => Err(webread_error_with(
481            &WEBREAD_ERROR_INVALID_OPTION_VALUE,
482            "webread: QueryParameters must be a struct or cell array",
483        )),
484    }
485}
486
487fn append_query_from_cell(
488    cell: &CellArray,
489    query_params: &mut Vec<(String, String)>,
490) -> BuiltinResult<()> {
491    if cell.cols != 2 {
492        return Err(webread_error(
493            "webread: cell array of query parameters must have two columns",
494        ));
495    }
496    for row in 0..cell.rows {
497        let name_value = cell
498            .get(row, 0)
499            .map_err(|err| webread_error(format!("webread: {err}")))?;
500        let value_value = cell
501            .get(row, 1)
502            .map_err(|err| webread_error(format!("webread: {err}")))?;
503        let name = expect_string_scalar(
504            &name_value,
505            "webread: query parameter names must be text scalars",
506        )?;
507        let text = value_to_query_string(&value_value, &name)?;
508        query_params.push((name, text));
509    }
510    Ok(())
511}
512
513fn execute_request(
514    url_text: &str,
515    options: WebReadOptions,
516    query_params: &[(String, String)],
517) -> BuiltinResult<Value> {
518    let username_present = options
519        .username
520        .as_ref()
521        .map(|s| !s.is_empty())
522        .unwrap_or(false);
523    let password_present = options
524        .password
525        .as_ref()
526        .map(|s| !s.is_empty())
527        .unwrap_or(false);
528    if password_present && !username_present {
529        return Err(webread_error_with(
530            &WEBREAD_ERROR_INVALID_CREDENTIALS,
531            "webread: Password requires a Username option",
532        ));
533    }
534
535    let mut url = Url::parse(url_text).map_err(|err| {
536        webread_error_with_source(
537            &WEBREAD_ERROR_INVALID_URL,
538            format!("webread: invalid URL '{url_text}': {err}"),
539            err,
540        )
541    })?;
542    if !query_params.is_empty() {
543        {
544            let mut pairs = url.query_pairs_mut();
545            for (name, value) in query_params {
546                pairs.append_pair(name, value);
547            }
548        }
549    }
550    let user_agent = options
551        .user_agent
552        .as_deref()
553        .filter(|ua| !ua.trim().is_empty())
554        .unwrap_or(DEFAULT_USER_AGENT)
555        .to_string();
556
557    let mut headers = options.headers.clone();
558    let has_auth_header = headers
559        .iter()
560        .any(|(name, _)| name.eq_ignore_ascii_case("authorization"));
561    if !has_auth_header {
562        if let Some(username) = options.username.as_ref().filter(|s| !s.is_empty()) {
563            let password = options.password.clone().unwrap_or_default();
564            let token = BASE64_ENGINE.encode(format!("{username}:{password}"));
565            headers.push(("Authorization".to_string(), format!("Basic {token}")));
566        }
567    }
568
569    let request = HttpRequest {
570        url,
571        method: HttpMethod::Get,
572        headers,
573        body: None,
574        timeout: options.timeout,
575        user_agent,
576    };
577
578    let response = transport::send_request(&request).map_err(|err| {
579        webread_error_with_source(
580            &WEBREAD_ERROR_TRANSPORT,
581            err.message_with_prefix("webread"),
582            err,
583        )
584    })?;
585
586    let header_content_type =
587        header_value(&response.headers, HEADER_CONTENT_TYPE).map(|value| value.to_string());
588    let resolved = options.resolve_content_type(header_content_type.as_deref());
589
590    match resolved {
591        ResolvedContentType::Json => {
592            let body = decode_body_as_text(&response.body, header_content_type.as_deref());
593            let value = decode_json_text(&body).map_err(map_json_error)?;
594            Ok(value)
595        }
596        ResolvedContentType::Text => {
597            let text = decode_body_as_text(&response.body, header_content_type.as_deref());
598            let array = CharArray::new_row(&text);
599            Ok(Value::CharArray(array))
600        }
601        ResolvedContentType::Binary => {
602            let data: Vec<f64> = response.body.iter().map(|b| f64::from(*b)).collect();
603            let cols = response.body.len();
604            let tensor = Tensor::new(data, vec![1, cols]).map_err(|err| {
605                webread_error_with(&WEBREAD_ERROR_OUTPUT, format!("webread: {err}"))
606            })?;
607            Ok(Value::Tensor(tensor))
608        }
609    }
610}
611
612fn map_json_error(err: RuntimeError) -> RuntimeError {
613    let message = if let Some(rest) = err.message().strip_prefix("jsondecode: ") {
614        format!("webread: failed to parse JSON response ({rest})")
615    } else {
616        format!("webread: failed to parse JSON response ({})", err.message())
617    };
618    webread_error_with_source(&WEBREAD_ERROR_RESPONSE_JSON, message, err)
619}
620
621fn parse_header_fields(value: &Value) -> BuiltinResult<Vec<(String, String)>> {
622    match value {
623        Value::Struct(struct_value) => {
624            let mut headers = Vec::with_capacity(struct_value.fields.len());
625            for (key, val) in &struct_value.fields {
626                let header_value = expect_string_scalar(
627                    val,
628                    "webread: header values must be character vectors or string scalars",
629                )?;
630                headers.push((key.clone(), header_value));
631            }
632            Ok(headers)
633        }
634        Value::Cell(cell) => {
635            if cell.cols != 2 {
636                return Err(webread_error(
637                    "webread: HeaderFields cell array must have exactly two columns",
638                ));
639            }
640            let mut headers = Vec::with_capacity(cell.rows);
641            for row in 0..cell.rows {
642                let name = cell
643                    .get(row, 0)
644                    .map_err(|err| webread_error(format!("webread: {err}")))?;
645                let value = cell
646                    .get(row, 1)
647                    .map_err(|err| webread_error(format!("webread: {err}")))?;
648                let header_name = expect_string_scalar(
649                    &name,
650                    "webread: header names must be character vectors or string scalars",
651                )?;
652                if header_name.trim().is_empty() {
653                    return Err(webread_error("webread: header names must not be empty"));
654                }
655                let header_value = expect_string_scalar(
656                    &value,
657                    "webread: header values must be character vectors or string scalars",
658                )?;
659                headers.push((header_name, header_value));
660            }
661            Ok(headers)
662        }
663        _ => Err(webread_error(
664            "webread: HeaderFields must be provided as a struct or cell array of name/value pairs",
665        )),
666    }
667}
668
669fn parse_content_type(value: &Value) -> BuiltinResult<ContentTypeHint> {
670    let text = expect_string_scalar(
671        value,
672        "webread: ContentType must be a character vector or string scalar",
673    )?;
674    match text.trim().to_ascii_lowercase().as_str() {
675        "auto" => Ok(ContentTypeHint::Auto),
676        "json" => Ok(ContentTypeHint::Json),
677        "text" | "char" | "string" => Ok(ContentTypeHint::Text),
678        "binary" | "octet-stream" | "raw" => Ok(ContentTypeHint::Binary),
679        other => Err(webread_error(format!(
680            "webread: unsupported ContentType '{}'; use 'auto', 'json', 'text', or 'binary'",
681            other
682        ))),
683    }
684}
685
686fn parse_timeout(value: &Value) -> BuiltinResult<Duration> {
687    let seconds = numeric_scalar(value, "webread: Timeout must be a finite, positive scalar")?;
688    if !seconds.is_finite() || seconds <= 0.0 {
689        return Err(webread_error(
690            "webread: Timeout must be a finite, positive scalar",
691        ));
692    }
693    Ok(Duration::from_secs_f64(seconds))
694}
695
696fn parse_request_method(value: &Value) -> BuiltinResult<HttpMethod> {
697    let text = expect_string_scalar(
698        value,
699        "webread: RequestMethod must be a character vector or string scalar",
700    )?;
701    let lower = text.trim().to_ascii_lowercase();
702    match lower.as_str() {
703        "get" | "auto" => Ok(HttpMethod::Get),
704        other => Err(webread_error(format!(
705            "webread: RequestMethod '{}' is not supported; expected 'auto' or 'get'",
706            other
707        ))),
708    }
709}
710
711fn numeric_scalar(value: &Value, context: &str) -> BuiltinResult<f64> {
712    match value {
713        Value::Num(n) => Ok(*n),
714        Value::Int(i) => Ok(i.to_f64()),
715        Value::Tensor(tensor) => {
716            if tensor.data.len() == 1 {
717                Ok(tensor.data[0])
718            } else {
719                Err(webread_error(context))
720            }
721        }
722        _ => Err(webread_error(context)),
723    }
724}
725
726fn expect_string_scalar(value: &Value, context: &str) -> BuiltinResult<String> {
727    match value {
728        Value::String(s) => Ok(s.clone()),
729        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
730        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
731        _ => Err(webread_error(context)),
732    }
733}
734
735fn value_to_query_string(value: &Value, name: &str) -> BuiltinResult<String> {
736    match value {
737        Value::String(s) => Ok(s.clone()),
738        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
739        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
740        Value::Num(n) => Ok(format!("{}", n)),
741        Value::Int(i) => Ok(i.to_i64().to_string()),
742        Value::Bool(b) => Ok(if *b { "true".into() } else { "false".into() }),
743        Value::Tensor(tensor) => {
744            if tensor.data.len() == 1 {
745                Ok(format!("{}", tensor.data[0]))
746            } else {
747                Err(webread_error(format!(
748                    "webread: query parameter '{}' must be scalar",
749                    name
750                )))
751            }
752        }
753        Value::LogicalArray(array) => {
754            if array.len() == 1 {
755                Ok(if array.data[0] != 0 {
756                    "true".into()
757                } else {
758                    "false".into()
759                })
760            } else {
761                Err(webread_error(format!(
762                    "webread: query parameter '{}' must be scalar",
763                    name
764                )))
765            }
766        }
767        _ => Err(webread_error(format!(
768            "webread: unsupported value type for query parameter '{}'",
769            name
770        ))),
771    }
772}
773
774#[derive(Clone, Copy, Debug)]
775enum ContentTypeHint {
776    Auto,
777    Text,
778    Json,
779    Binary,
780}
781
782#[derive(Clone, Copy, Debug)]
783enum ResolvedContentType {
784    Text,
785    Json,
786    Binary,
787}
788
789#[derive(Clone, Debug)]
790struct WebReadOptions {
791    content_type: ContentTypeHint,
792    timeout: Duration,
793    headers: Vec<(String, String)>,
794    user_agent: Option<String>,
795    username: Option<String>,
796    password: Option<String>,
797    method: HttpMethod,
798}
799
800impl Default for WebReadOptions {
801    fn default() -> Self {
802        Self {
803            content_type: ContentTypeHint::Auto,
804            timeout: Duration::from_secs_f64(DEFAULT_TIMEOUT_SECONDS),
805            headers: Vec::new(),
806            user_agent: None,
807            username: None,
808            password: None,
809            method: HttpMethod::Get,
810        }
811    }
812}
813
814impl WebReadOptions {
815    fn resolve_content_type(&self, header: Option<&str>) -> ResolvedContentType {
816        match self.content_type {
817            ContentTypeHint::Json => ResolvedContentType::Json,
818            ContentTypeHint::Text => ResolvedContentType::Text,
819            ContentTypeHint::Binary => ResolvedContentType::Binary,
820            ContentTypeHint::Auto => infer_content_type(header),
821        }
822    }
823}
824
825fn infer_content_type(header: Option<&str>) -> ResolvedContentType {
826    if let Some(raw) = header {
827        let mime = raw
828            .split(';')
829            .next()
830            .map(|part| part.trim().to_ascii_lowercase())
831            .unwrap_or_default();
832        if mime == "application/json" || mime == "text/json" || mime.ends_with("+json") {
833            ResolvedContentType::Json
834        } else if mime.starts_with("text/")
835            || mime == "application/xml"
836            || mime.ends_with("+xml")
837            || mime == "application/xhtml+xml"
838            || mime == "application/javascript"
839            || mime == "application/x-www-form-urlencoded"
840        {
841            ResolvedContentType::Text
842        } else {
843            ResolvedContentType::Binary
844        }
845    } else {
846        ResolvedContentType::Text
847    }
848}
849
850#[cfg(test)]
851pub(crate) mod tests {
852    use super::*;
853    use std::io::{Read, Write};
854    use std::net::{TcpListener, TcpStream};
855    use std::sync::mpsc;
856    use std::thread;
857
858    fn error_message(err: RuntimeError) -> String {
859        err.message().to_string()
860    }
861
862    fn run_webread(url: Value, args: Vec<Value>) -> BuiltinResult<Value> {
863        futures::executor::block_on(webread_builtin(url, args))
864    }
865
866    fn spawn_server<F>(handler: F) -> String
867    where
868        F: FnOnce(TcpStream) + Send + 'static,
869    {
870        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
871        let addr = listener.local_addr().unwrap();
872        thread::spawn(move || {
873            if let Ok((stream, _)) = listener.accept() {
874                handler(stream);
875            }
876        });
877        format!("http://{}", addr)
878    }
879
880    fn respond_with(mut stream: TcpStream, content_type: &str, body: &[u8]) {
881        let response = format!(
882            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n",
883            body.len(),
884            content_type
885        );
886        let _ = stream.write_all(response.as_bytes());
887        let _ = stream.write_all(body);
888    }
889
890    fn read_request_headers(stream: &mut TcpStream) -> String {
891        let mut buffer = Vec::new();
892        let mut chunk = [0u8; 256];
893        while let Ok(read) = stream.read(&mut chunk) {
894            if read == 0 {
895                break;
896            }
897            buffer.extend_from_slice(&chunk[..read]);
898            if buffer.windows(4).any(|w| w == b"\r\n\r\n") {
899                break;
900            }
901            if buffer.len() > 16 * 1024 {
902                break;
903            }
904        }
905        String::from_utf8_lossy(&buffer).to_string()
906    }
907
908    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
909    #[test]
910    fn webread_descriptor_signatures_cover_core_forms() {
911        let labels: Vec<&str> = WEBREAD_DESCRIPTOR
912            .signatures
913            .iter()
914            .map(|sig| sig.label)
915            .collect();
916        assert!(labels.contains(&"data = webread(url)"));
917        assert!(labels.contains(&"data = webread(url, optionsStruct)"));
918        assert!(labels.contains(&"data = webread(url, name, value, ...)"));
919    }
920
921    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
922    #[test]
923    fn webread_fetches_json_response() {
924        let url = spawn_server(|mut stream| {
925            let mut buffer = [0u8; 1024];
926            let _ = stream.read(&mut buffer);
927            respond_with(
928                stream,
929                "application/json",
930                br#"{"message":"hello","value":42}"#,
931            );
932        });
933
934        let result = run_webread(Value::from(url), vec![]).expect("webread JSON response");
935
936        match result {
937            Value::Struct(struct_value) => {
938                let message = struct_value.fields.get("message").expect("message field");
939                let value = struct_value.fields.get("value").expect("value field");
940                match message {
941                    Value::CharArray(ca) => {
942                        let text: String = ca.data.iter().collect();
943                        assert_eq!(text, "hello");
944                    }
945                    other => panic!("expected char array, got {other:?}"),
946                }
947                match value {
948                    Value::Num(n) => assert_eq!(*n, 42.0),
949                    other => panic!("expected numeric value, got {other:?}"),
950                }
951            }
952            other => panic!("expected struct, got {other:?}"),
953        }
954    }
955
956    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
957    #[test]
958    fn webread_fetches_text_response() {
959        let url = spawn_server(|mut stream| {
960            let mut buffer = [0u8; 512];
961            let _ = stream.read(&mut buffer);
962            respond_with(stream, "text/plain; charset=utf-8", b"RunMat webread test");
963        });
964
965        let result = run_webread(Value::from(url), vec![]).expect("webread text response");
966
967        match result {
968            Value::CharArray(ca) => {
969                let text: String = ca.data.iter().collect();
970                assert_eq!(text, "RunMat webread test");
971            }
972            other => panic!("expected char array, got {other:?}"),
973        }
974    }
975
976    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
977    #[test]
978    fn webread_fetches_binary_payload() {
979        let payload = [1u8, 2, 3, 254, 255];
980        let url = spawn_server(move |mut stream| {
981            let mut buffer = [0u8; 512];
982            let _ = stream.read(&mut buffer);
983            respond_with(stream, "application/octet-stream", &payload);
984        });
985
986        let args = vec![Value::from("ContentType"), Value::from("binary")];
987        let result = run_webread(Value::from(url), args).expect("webread binary response");
988
989        match result {
990            Value::Tensor(tensor) => {
991                assert_eq!(tensor.shape, vec![1, 5]);
992                let bytes: Vec<u8> = tensor.data.iter().map(|v| *v as u8).collect();
993                assert_eq!(bytes, payload);
994            }
995            other => panic!("expected tensor, got {other:?}"),
996        }
997    }
998
999    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1000    #[test]
1001    fn webread_appends_query_parameters() {
1002        let (tx, rx) = mpsc::channel();
1003        let url = spawn_server(move |mut stream| {
1004            let request = read_request_headers(&mut stream);
1005            let _ = tx.send(request);
1006            respond_with(stream, "application/json", br#"{"ok":true}"#);
1007        });
1008
1009        let args = vec![
1010            Value::from("count"),
1011            Value::Num(42.0),
1012            Value::from("ContentType"),
1013            Value::from("json"),
1014        ];
1015        let result = run_webread(Value::from(url.clone()), args).expect("webread query");
1016        match result {
1017            Value::Struct(struct_value) => {
1018                assert!(struct_value.fields.contains_key("ok"));
1019            }
1020            other => panic!("expected struct result, got {other:?}"),
1021        }
1022        let request = rx.recv().expect("request log");
1023        assert!(
1024            request.starts_with("GET /"),
1025            "unexpected request line: {request}"
1026        );
1027        assert!(
1028            request.contains("count=42"),
1029            "query parameters missing: {request}"
1030        );
1031    }
1032
1033    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1034    #[test]
1035    fn webread_struct_argument_supports_options_and_query() {
1036        let (tx, rx) = mpsc::channel();
1037        let url = spawn_server(move |mut stream| {
1038            let request = read_request_headers(&mut stream);
1039            let _ = tx.send(request);
1040            respond_with(stream, "application/json", br#"{"value":123}"#);
1041        });
1042
1043        let mut fields = StructValue::new();
1044        fields
1045            .fields
1046            .insert("ContentType".to_string(), Value::from("json"));
1047        fields.fields.insert("limit".to_string(), Value::Num(5.0));
1048
1049        let result = run_webread(Value::from(url.clone()), vec![Value::Struct(fields)])
1050            .expect("webread struct arg");
1051
1052        let request = rx.recv().expect("request log");
1053        assert!(
1054            request.contains("GET /?limit=5"),
1055            "expected limit query parameter: {request}"
1056        );
1057
1058        match result {
1059            Value::Struct(struct_value) => match struct_value.fields.get("value") {
1060                Some(Value::Num(n)) => assert_eq!(*n, 123.0),
1061                other => panic!("unexpected JSON decode result: {other:?}"),
1062            },
1063            other => panic!("expected struct, got {other:?}"),
1064        }
1065    }
1066
1067    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1068    #[test]
1069    fn webread_headerfields_struct_applies_custom_headers() {
1070        let (tx, rx) = mpsc::channel();
1071        let url = spawn_server(move |mut stream| {
1072            let request = read_request_headers(&mut stream);
1073            let _ = tx.send(request);
1074            respond_with(stream, "application/json", br#"{"ok":true}"#);
1075        });
1076
1077        let mut headers = StructValue::new();
1078        headers
1079            .fields
1080            .insert("X-Test".to_string(), Value::from("RunMat"));
1081
1082        let args = vec![
1083            Value::from("HeaderFields"),
1084            Value::Struct(headers),
1085            Value::from("ContentType"),
1086            Value::from("json"),
1087        ];
1088
1089        let result = run_webread(Value::from(url), args).expect("webread header fields");
1090        assert!(matches!(result, Value::Struct(_)));
1091
1092        let request = rx.recv().expect("request log");
1093        assert!(
1094            request.to_ascii_lowercase().contains("x-test: runmat"),
1095            "custom header missing: {request}"
1096        );
1097    }
1098
1099    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1100    #[test]
1101    fn webread_queryparameters_option_struct() {
1102        let (tx, rx) = mpsc::channel();
1103        let url = spawn_server(move |mut stream| {
1104            let request = read_request_headers(&mut stream);
1105            let _ = tx.send(request);
1106            respond_with(stream, "application/json", br#"{"ok":true}"#);
1107        });
1108
1109        let mut params = StructValue::new();
1110        params.fields.insert("page".to_string(), Value::Num(2.0));
1111
1112        let args = vec![
1113            Value::from("QueryParameters"),
1114            Value::Struct(params),
1115            Value::from("ContentType"),
1116            Value::from("json"),
1117        ];
1118
1119        let result = run_webread(Value::from(url.clone()), args).expect("webread query parameters");
1120        assert!(matches!(result, Value::Struct(_)));
1121
1122        let request = rx.recv().expect("request log");
1123        assert!(
1124            request.contains("page=2"),
1125            "query parameter missing: {request}"
1126        );
1127    }
1128
1129    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1130    #[test]
1131    fn webread_errors_on_missing_name_value_pair() {
1132        let err = run_webread(
1133            Value::from("https://example.com"),
1134            vec![Value::from("Timeout")],
1135        )
1136        .expect_err("expected missing value error");
1137        let err = error_message(err);
1138        assert!(
1139            err.contains("missing value"),
1140            "unexpected error message: {err}"
1141        );
1142    }
1143
1144    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1145    #[test]
1146    fn webread_rejects_non_positive_timeout() {
1147        let args = vec![Value::from("Timeout"), Value::Num(0.0)];
1148        let err = run_webread(Value::from("https://example.com"), args).expect_err("timeout error");
1149        let err = error_message(err);
1150        assert!(
1151            err.contains("Timeout must be a finite, positive scalar"),
1152            "unexpected error message: {err}"
1153        );
1154    }
1155
1156    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1157    #[test]
1158    fn webread_rejects_password_without_username() {
1159        let args = vec![Value::from("Password"), Value::from("secret")];
1160        let err = run_webread(Value::from("https://example.com"), args).expect_err("auth error");
1161        let err = error_message(err);
1162        assert!(
1163            err.contains("Password requires a Username"),
1164            "unexpected error message: {err}"
1165        );
1166    }
1167
1168    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1169    #[test]
1170    fn webread_rejects_unsupported_content_type() {
1171        let args = vec![Value::from("ContentType"), Value::from("table")];
1172        let err = run_webread(Value::from("https://example.com"), args).expect_err("format error");
1173        let err = error_message(err);
1174        assert!(
1175            err.contains("unsupported ContentType"),
1176            "unexpected error message: {err}"
1177        );
1178    }
1179
1180    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1181    #[test]
1182    fn webread_rejects_invalid_headerfields_shape() {
1183        let cell = crate::make_cell(
1184            vec![Value::from("A"), Value::from("B"), Value::from("C")],
1185            1,
1186            3,
1187        )
1188        .expect("make cell");
1189
1190        let args = vec![Value::from("HeaderFields"), cell];
1191        let err = run_webread(Value::from("https://example.com"), args).expect_err("header error");
1192        let err = error_message(err);
1193        assert!(
1194            err.contains("HeaderFields cell array must have exactly two columns"),
1195            "unexpected error message: {err}"
1196        );
1197    }
1198}