1use 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 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}