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