1use std::collections::VecDeque;
4
5use runmat_builtins::{StructValue, Value};
6use runmat_macros::runtime_builtin;
7
8use crate::builtins::common::spec::{
9 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
10 ReductionNaN, ResidencyPolicy, ShapeRequirements,
11};
12use crate::gather_if_needed;
13#[cfg(feature = "doc_export")]
14use crate::register_builtin_doc_text;
15use crate::{register_builtin_fusion_spec, register_builtin_gpu_spec};
16
17const DEFAULT_TIMEOUT_SECONDS: f64 = 60.0;
18
19#[cfg(feature = "doc_export")]
20#[allow(clippy::too_many_lines)]
21pub const DOC_MD: &str = r#"---
22title: "weboptions"
23category: "io/http"
24keywords: ["weboptions", "http options", "timeout", "headers", "rest client"]
25summary: "Create an options struct that configures webread and webwrite HTTP behaviour."
26references:
27 - https://www.mathworks.com/help/matlab/ref/weboptions.html
28gpu_support:
29 elementwise: false
30 reduction: false
31 precisions: []
32 broadcasting: "none"
33 notes: "weboptions operates on CPU data structures and gathers gpuArray inputs automatically."
34fusion:
35 elementwise: false
36 reduction: false
37 max_inputs: 1
38 constants: "inline"
39requires_feature: null
40tested:
41 unit: "builtins::io::http::weboptions::tests"
42 integration:
43 - "builtins::io::http::weboptions::tests::weboptions_default_struct_matches_expected_fields"
44 - "builtins::io::http::weboptions::tests::weboptions_overrides_timeout_and_headers"
45 - "builtins::io::http::weboptions::tests::weboptions_updates_existing_struct"
46 - "builtins::io::http::weboptions::tests::weboptions_rejects_unknown_option"
47 - "builtins::io::http::weboptions::tests::weboptions_requires_username_when_password_provided"
48 - "builtins::io::http::weboptions::tests::weboptions_rejects_timeout_nonpositive"
49 - "builtins::io::http::weboptions::tests::weboptions_rejects_headerfields_bad_cell_shape"
50 - "builtins::io::http::weboptions::tests::webread_uses_weboptions_without_polluting_query"
51 - "builtins::io::http::weboptions::tests::webwrite_uses_weboptions_auto_request_method"
52---
53
54# What does the `weboptions` function do in MATLAB / RunMat?
55`weboptions` builds a MATLAB-style options struct that controls HTTP behaviour for
56functions such as `webread`, `webwrite`, and `websave`. The struct stores option
57fields like `Timeout`, `ContentType`, `HeaderFields`, and `RequestMethod`, all with
58MATLAB-compatible defaults.
59
60## How does the `weboptions` function behave in MATLAB / RunMat?
61- Returns a struct with canonical field names: `ContentType`, `Timeout`, `HeaderFields`,
62 `UserAgent`, `Username`, `Password`, `RequestMethod`, `MediaType`, and `QueryParameters`.
63- Defaults mirror MATLAB: `ContentType="auto"`, `Timeout=60`, `UserAgent=""`
64 (RunMat substitutes a default agent when this is empty), `RequestMethod="auto"`,
65 `MediaType="auto"`, and empty structs for `HeaderFields` and `QueryParameters`.
66- Name-value arguments are case-insensitive. Values are validated to ensure MATLAB-compatible
67 types (text scalars for string options, positive scalars for `Timeout`,
68 structs or two-column cell arrays for `HeaderFields` and `QueryParameters`).
69- Passing an existing options struct as the first argument clones it before applying additional
70 overrides, matching MATLAB's update pattern `opts = weboptions(opts, "Timeout", 5)`.
71- Unknown option names raise descriptive errors.
72
73## `weboptions` Function GPU Execution Behaviour
74`weboptions` operates entirely on CPU metadata. It gathers any `gpuArray` inputs back to host
75memory before validation, because HTTP requests execute on the CPU regardless of the selected
76acceleration provider. No GPU provider hooks are required for this function.
77
78## Examples of using the `weboptions` function in MATLAB / RunMat
79
80### Setting custom timeouts for webread calls
81```matlab
82opts = weboptions("Timeout", 10);
83html = webread("https://example.com", opts);
84```
85The request aborts after 10 seconds instead of the default 60.
86
87### Providing HTTP basic authentication credentials
88```matlab
89opts = weboptions("Username", "ada", "Password", "lovelace");
90profile = webread("https://api.example.com/me", opts);
91```
92Credentials are attached automatically; an empty username leaves authentication disabled.
93
94### Sending JSON payloads with webwrite
95```matlab
96opts = weboptions("ContentType", "json", "MediaType", "application/json");
97payload = struct("title", "RunMat", "stars", 5);
98reply = webwrite("https://api.example.com/projects", payload, opts);
99```
100The request posts JSON and expects a JSON response.
101
102### Applying custom headers with struct syntax
103```matlab
104headers = struct("Accept", "application/json", "X-Client", "RunMat");
105opts = weboptions("HeaderFields", headers);
106data = webread("https://api.example.com/resources", opts);
107```
108`HeaderFields` accepts a struct or two-column cell array of header name/value pairs.
109
110### Combining existing options with overrides
111```matlab
112base = weboptions("ContentType", "json");
113opts = weboptions(base, "Timeout", 15, "QueryParameters", struct("verbose", true));
114result = webread("https://api.example.com/items", opts);
115```
116The new struct inherits all fields from `base` and overrides the ones supplied later.
117
118## FAQ
119
120### Which option names are supported in RunMat?
121`weboptions` implements the options consumed by `webread` and `webwrite`: `ContentType`,
122`Timeout`, `HeaderFields`, `UserAgent`, `Username`, `Password`, `RequestMethod`, `MediaType`,
123and `QueryParameters`. Unknown names raise a MATLAB-style error.
124
125### What does `RequestMethod="auto"` mean?
126`webread` treats `"auto"` as `"get"` while `webwrite` maps it to `"post"`. Override the method when
127you need `put`, `patch`, or `delete`.
128
129### How are empty usernames or passwords handled?
130Empty strings leave authentication disabled. A non-empty password without a username raises a
131MATLAB-compatible error.
132
133### Can I pass query parameters through the options struct?
134Yes. Supply a struct or two-column cell array in the `QueryParameters` option. Values may include
135numbers, logicals, or text scalars, and they are percent-encoded when the request is built.
136
137### Do I need to manage GPU residency for options?
138No. `weboptions` gathers any GPU-resident values automatically and always returns a host struct.
139HTTP builtins ignore GPU residency for metadata.
140
141### Does `weboptions` mutate the input struct?
142No. A copy is made before overrides are applied, preserving the original struct you pass in.
143
144### How can I clear headers or query parameters?
145Pass an empty struct (`struct()`) or empty cell array (`{}`) to reset the respective option.
146
147## See Also
148[webread](./webread), [webwrite](./webwrite), [jsondecode](../json/jsondecode), [jsonencode](../json/jsonencode)
149"#;
150
151pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
152 name: "weboptions",
153 op_kind: GpuOpKind::Custom("http-options"),
154 supported_precisions: &[],
155 broadcast: BroadcastSemantics::None,
156 provider_hooks: &[],
157 constant_strategy: ConstantStrategy::InlineLiteral,
158 residency: ResidencyPolicy::GatherImmediately,
159 nan_mode: ReductionNaN::Include,
160 two_pass_threshold: None,
161 workgroup_size: None,
162 accepts_nan_mode: false,
163 notes: "weboptions validates CPU metadata only; gpuArray inputs are gathered eagerly.",
164};
165
166register_builtin_gpu_spec!(GPU_SPEC);
167
168pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
169 name: "weboptions",
170 shape: ShapeRequirements::Any,
171 constant_strategy: ConstantStrategy::InlineLiteral,
172 elementwise: None,
173 reduction: None,
174 emits_nan: false,
175 notes: "weboptions constructs option structs and terminates fusion graphs.",
176};
177
178register_builtin_fusion_spec!(FUSION_SPEC);
179
180#[cfg(feature = "doc_export")]
181register_builtin_doc_text!("weboptions", DOC_MD);
182
183#[runtime_builtin(
184 name = "weboptions",
185 category = "io/http",
186 summary = "Create an options struct that configures webread and webwrite HTTP behaviour.",
187 keywords = "weboptions,http options,timeout,headers,rest client",
188 accel = "cpu"
189)]
190fn weboptions_builtin(rest: Vec<Value>) -> Result<Value, String> {
191 let mut gathered = Vec::with_capacity(rest.len());
192 for value in rest {
193 gathered.push(gather_if_needed(&value).map_err(|e| format!("weboptions: {e}"))?);
194 }
195 let mut queue: VecDeque<Value> = gathered.into();
196 let mut options = default_options_struct();
197
198 if matches!(queue.front(), Some(Value::Struct(_))) {
199 if let Some(Value::Struct(struct_value)) = queue.pop_front() {
200 apply_struct_fields(struct_value, &mut options)?;
201 }
202 }
203
204 while let Some(name_value) = queue.pop_front() {
205 let name = expect_string_scalar(
206 &name_value,
207 "weboptions: option names must be character vectors or string scalars",
208 )?;
209 let value = queue
210 .pop_front()
211 .ok_or_else(|| "weboptions: missing value for name-value argument".to_string())?;
212 set_option_field(&mut options, &name, &value)?;
213 }
214
215 validate_credentials(&options)?;
216
217 Ok(Value::Struct(options))
218}
219
220fn default_options_struct() -> StructValue {
221 let mut out = StructValue::new();
222 out.fields
223 .insert("ContentType".to_string(), Value::from("auto"));
224 out.fields
225 .insert("Timeout".to_string(), Value::Num(DEFAULT_TIMEOUT_SECONDS));
226 out.fields.insert(
227 "HeaderFields".to_string(),
228 Value::Struct(StructValue::new()),
229 );
230 out.fields.insert("UserAgent".to_string(), Value::from(""));
231 out.fields.insert("Username".to_string(), Value::from(""));
232 out.fields.insert("Password".to_string(), Value::from(""));
233 out.fields
234 .insert("RequestMethod".to_string(), Value::from("auto"));
235 out.fields
236 .insert("MediaType".to_string(), Value::from("auto"));
237 out.fields.insert(
238 "QueryParameters".to_string(),
239 Value::Struct(StructValue::new()),
240 );
241 out
242}
243
244fn apply_struct_fields(source: StructValue, target: &mut StructValue) -> Result<(), String> {
245 for (key, value) in &source.fields {
246 set_option_field(target, key, value)?;
247 }
248 Ok(())
249}
250
251fn set_option_field(options: &mut StructValue, name: &str, value: &Value) -> Result<(), String> {
252 let lower = name.to_ascii_lowercase();
253 match lower.as_str() {
254 "contenttype" => {
255 let canonical = parse_content_type_option(value)?;
256 options
257 .fields
258 .insert("ContentType".to_string(), Value::from(canonical));
259 Ok(())
260 }
261 "timeout" => {
262 let seconds = numeric_scalar(
263 value,
264 "weboptions: Timeout must be a finite, positive scalar",
265 )?;
266 if !seconds.is_finite() || seconds <= 0.0 {
267 return Err("weboptions: Timeout must be a finite, positive scalar".to_string());
268 }
269 options
270 .fields
271 .insert("Timeout".to_string(), Value::Num(seconds));
272 Ok(())
273 }
274 "headerfields" => {
275 let canonical = canonical_header_fields(value)?;
276 options.fields.insert("HeaderFields".to_string(), canonical);
277 Ok(())
278 }
279 "useragent" => {
280 let ua = expect_string_scalar(
281 value,
282 "weboptions: UserAgent must be a character vector or string scalar",
283 )?;
284 options
285 .fields
286 .insert("UserAgent".to_string(), Value::from(ua));
287 Ok(())
288 }
289 "username" => {
290 let username = expect_string_scalar(
291 value,
292 "weboptions: Username must be a character vector or string scalar",
293 )?;
294 options
295 .fields
296 .insert("Username".to_string(), Value::from(username));
297 Ok(())
298 }
299 "password" => {
300 let password = expect_string_scalar(
301 value,
302 "weboptions: Password must be a character vector or string scalar",
303 )?;
304 options
305 .fields
306 .insert("Password".to_string(), Value::from(password));
307 Ok(())
308 }
309 "requestmethod" => {
310 let method = parse_request_method_option(value)?;
311 options
312 .fields
313 .insert("RequestMethod".to_string(), Value::from(method));
314 Ok(())
315 }
316 "mediatype" => {
317 let media = expect_string_scalar(
318 value,
319 "weboptions: MediaType must be a character vector or string scalar",
320 )?;
321 options
322 .fields
323 .insert("MediaType".to_string(), Value::from(media));
324 Ok(())
325 }
326 "queryparameters" => {
327 let qp = canonical_query_parameters(value)?;
328 options.fields.insert("QueryParameters".to_string(), qp);
329 Ok(())
330 }
331 _ => Err(format!("weboptions: unknown option '{}'", name)),
332 }
333}
334
335fn parse_content_type_option(value: &Value) -> Result<String, String> {
336 let text = expect_string_scalar(
337 value,
338 "weboptions: ContentType must be a character vector or string scalar",
339 )?;
340 match text.trim().to_ascii_lowercase().as_str() {
341 "auto" => Ok("auto".to_string()),
342 "json" => Ok("json".to_string()),
343 "text" | "char" | "string" => Ok("text".to_string()),
344 "binary" | "raw" | "octet-stream" => Ok("binary".to_string()),
345 other => Err(format!(
346 "weboptions: unsupported ContentType '{}'; use 'auto', 'json', 'text', or 'binary'",
347 other
348 )),
349 }
350}
351
352fn parse_request_method_option(value: &Value) -> Result<String, String> {
353 let text = expect_string_scalar(
354 value,
355 "weboptions: RequestMethod must be a character vector or string scalar",
356 )?;
357 let lower = text.trim().to_ascii_lowercase();
358 match lower.as_str() {
359 "auto" | "get" | "post" | "put" | "patch" | "delete" => Ok(lower),
360 _ => Err(format!(
361 "weboptions: unsupported RequestMethod '{}'; expected auto, get, post, put, patch, or delete",
362 text
363 )),
364 }
365}
366
367fn canonical_header_fields(value: &Value) -> Result<Value, String> {
368 match value {
369 Value::Struct(struct_value) => {
370 let mut out = StructValue::new();
371 for (key, val) in &struct_value.fields {
372 let header_value = expect_string_scalar(
373 val,
374 "weboptions: HeaderFields values must be character vectors or string scalars",
375 )?;
376 if header_value.trim().is_empty() {
377 return Err("weboptions: header values must not be empty".to_string());
378 }
379 if key.trim().is_empty() {
380 return Err("weboptions: header names must not be empty".to_string());
381 }
382 out.fields.insert(key.clone(), Value::from(header_value));
383 }
384 Ok(Value::Struct(out))
385 }
386 Value::Cell(cell) => {
387 if cell.cols != 2 {
388 return Err(
389 "weboptions: HeaderFields cell array must have exactly two columns".to_string(),
390 );
391 }
392 let mut out = StructValue::new();
393 for row in 0..cell.rows {
394 let name_val = cell.get(row, 0).map_err(|e| format!("weboptions: {e}"))?;
395 let value_val = cell.get(row, 1).map_err(|e| format!("weboptions: {e}"))?;
396 let name = expect_string_scalar(
397 &name_val,
398 "weboptions: header names must be character vectors or string scalars",
399 )?;
400 if name.trim().is_empty() {
401 return Err("weboptions: header names must not be empty".to_string());
402 }
403 let header_value = expect_string_scalar(
404 &value_val,
405 "weboptions: header values must be character vectors or string scalars",
406 )?;
407 if header_value.trim().is_empty() {
408 return Err("weboptions: header values must not be empty".to_string());
409 }
410 out.fields.insert(name, Value::from(header_value));
411 }
412 Ok(Value::Struct(out))
413 }
414 _ => Err("weboptions: HeaderFields must be a struct or two-column cell array".to_string()),
415 }
416}
417
418fn canonical_query_parameters(value: &Value) -> Result<Value, String> {
419 match value {
420 Value::Struct(struct_value) => {
421 let mut out = StructValue::new();
422 for (key, val) in &struct_value.fields {
423 out.fields.insert(key.clone(), val.clone());
424 }
425 Ok(Value::Struct(out))
426 }
427 Value::Cell(cell) => {
428 if cell.cols != 2 {
429 return Err(
430 "weboptions: QueryParameters cell array must have exactly two columns"
431 .to_string(),
432 );
433 }
434 let mut out = StructValue::new();
435 for row in 0..cell.rows {
436 let name_val = cell.get(row, 0).map_err(|e| format!("weboptions: {e}"))?;
437 let value_val = cell.get(row, 1).map_err(|e| format!("weboptions: {e}"))?;
438 let name = expect_string_scalar(
439 &name_val,
440 "weboptions: query parameter names must be character vectors or string scalars",
441 )?;
442 out.fields.insert(name, value_val);
443 }
444 Ok(Value::Struct(out))
445 }
446 _ => {
447 Err("weboptions: QueryParameters must be a struct or two-column cell array".to_string())
448 }
449 }
450}
451
452fn validate_credentials(options: &StructValue) -> Result<(), String> {
453 let username = string_field(options, "Username").unwrap_or_default();
454 let password = string_field(options, "Password").unwrap_or_default();
455 if !password.trim().is_empty() && username.trim().is_empty() {
456 return Err("weboptions: Password requires a Username option".to_string());
457 }
458 Ok(())
459}
460
461fn string_field(options: &StructValue, field: &str) -> Option<String> {
462 options.fields.get(field).and_then(|value| match value {
463 Value::String(text) => Some(text.clone()),
464 Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
465 Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
466 _ => None,
467 })
468}
469
470fn numeric_scalar(value: &Value, context: &str) -> Result<f64, String> {
471 match value {
472 Value::Num(n) => Ok(*n),
473 Value::Int(i) => Ok(i.to_f64()),
474 Value::Tensor(tensor) => {
475 if tensor.data.len() == 1 {
476 Ok(tensor.data[0])
477 } else {
478 Err(context.to_string())
479 }
480 }
481 _ => Err(context.to_string()),
482 }
483}
484
485fn expect_string_scalar(value: &Value, context: &str) -> Result<String, String> {
486 match value {
487 Value::String(s) => Ok(s.clone()),
488 Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
489 Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
490 _ => Err(context.to_string()),
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use std::io::{Read, Write};
498 use std::net::{TcpListener, TcpStream};
499 use std::sync::mpsc;
500 use std::thread;
501
502 use crate::call_builtin;
503 use runmat_builtins::CellArray;
504
505 #[cfg(feature = "doc_export")]
506 use crate::builtins::common::test_support;
507
508 fn spawn_server<F>(handler: F) -> String
509 where
510 F: FnOnce(TcpStream) + Send + 'static,
511 {
512 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
513 let addr = listener.local_addr().unwrap();
514 thread::spawn(move || {
515 if let Ok((stream, _)) = listener.accept() {
516 handler(stream);
517 }
518 });
519 format!("http://{}", addr)
520 }
521
522 fn read_request(stream: &mut TcpStream) -> (String, Vec<u8>) {
523 let mut buffer = Vec::new();
524 let mut tmp = [0u8; 512];
525 loop {
526 match stream.read(&mut tmp) {
527 Ok(0) => break,
528 Ok(n) => {
529 buffer.extend_from_slice(&tmp[..n]);
530 if buffer.windows(4).any(|w| w == b"\r\n\r\n") {
531 break;
532 }
533 }
534 Err(_) => break,
535 }
536 }
537 let header_end = buffer
538 .windows(4)
539 .position(|w| w == b"\r\n\r\n")
540 .map(|idx| idx + 4)
541 .unwrap_or(buffer.len());
542 let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
543 let body = buffer[header_end..].to_vec();
544 (headers, body)
545 }
546
547 fn respond_with(mut stream: TcpStream, content_type: &str, body: &[u8]) {
548 let response = format!(
549 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n",
550 body.len(),
551 content_type
552 );
553 let _ = stream.write_all(response.as_bytes());
554 let _ = stream.write_all(body);
555 }
556
557 #[test]
558 fn weboptions_default_struct_matches_expected_fields() {
559 let result = weboptions_builtin(Vec::new()).expect("weboptions");
560 let Value::Struct(options) = result else {
561 panic!("expected struct result");
562 };
563 assert_eq!(
564 options.fields.get("ContentType").and_then(|v| match v {
565 Value::String(s) => Some(s.as_str()),
566 _ => None,
567 }),
568 Some("auto")
569 );
570 assert_eq!(
571 options.fields.get("Timeout").and_then(|v| match v {
572 Value::Num(n) => Some(*n),
573 _ => None,
574 }),
575 Some(DEFAULT_TIMEOUT_SECONDS)
576 );
577 match options.fields.get("HeaderFields") {
578 Some(Value::Struct(headers)) => assert!(headers.fields.is_empty()),
579 other => panic!("expected empty HeaderFields struct, got {other:?}"),
580 }
581 assert_eq!(
582 options.fields.get("RequestMethod").and_then(|v| match v {
583 Value::String(s) => Some(s.as_str()),
584 _ => None,
585 }),
586 Some("auto")
587 );
588 assert_eq!(
589 options.fields.get("MediaType").and_then(|v| match v {
590 Value::String(s) => Some(s.as_str()),
591 _ => None,
592 }),
593 Some("auto")
594 );
595 }
596
597 #[test]
598 fn weboptions_overrides_timeout_and_headers() {
599 let mut headers = StructValue::new();
600 headers
601 .fields
602 .insert("Accept".to_string(), Value::from("application/json"));
603 headers
604 .fields
605 .insert("X-Client".to_string(), Value::from("RunMat"));
606 let args = vec![
607 Value::from("Timeout"),
608 Value::Num(10.0),
609 Value::from("HeaderFields"),
610 Value::Struct(headers),
611 ];
612 let result = weboptions_builtin(args).expect("weboptions overrides");
613 let Value::Struct(opts) = result else {
614 panic!("expected struct");
615 };
616 assert_eq!(
617 opts.fields.get("Timeout").and_then(|v| match v {
618 Value::Num(n) => Some(*n),
619 _ => None,
620 }),
621 Some(10.0)
622 );
623 match opts.fields.get("HeaderFields") {
624 Some(Value::Struct(headers)) => {
625 assert_eq!(
626 headers.fields.get("Accept"),
627 Some(&Value::from("application/json"))
628 );
629 assert_eq!(headers.fields.get("X-Client"), Some(&Value::from("RunMat")));
630 }
631 other => panic!("expected header struct, got {other:?}"),
632 }
633 }
634
635 #[test]
636 fn weboptions_updates_existing_struct() {
637 let base = weboptions_builtin(vec![Value::from("ContentType"), Value::from("json")])
638 .expect("base weboptions");
639 let args = vec![base, Value::from("Timeout"), Value::Num(15.0)];
640 let updated = weboptions_builtin(args).expect("weboptions update");
641 let Value::Struct(opts) = updated else {
642 panic!("expected struct");
643 };
644 assert_eq!(
645 opts.fields.get("ContentType").and_then(|v| match v {
646 Value::String(s) => Some(s.as_str()),
647 _ => None,
648 }),
649 Some("json")
650 );
651 assert_eq!(
652 opts.fields.get("Timeout").and_then(|v| match v {
653 Value::Num(n) => Some(*n),
654 _ => None,
655 }),
656 Some(15.0)
657 );
658 }
659
660 #[test]
661 fn weboptions_rejects_unknown_option() {
662 let err = weboptions_builtin(vec![Value::from("BogusOption"), Value::Num(1.0)])
663 .expect_err("unknown option should fail");
664 assert!(err.contains("unknown option"), "unexpected error: {err}");
665 }
666
667 #[test]
668 fn weboptions_requires_username_when_password_provided() {
669 let err = weboptions_builtin(vec![Value::from("Password"), Value::from("secret")])
670 .expect_err("password without username");
671 assert!(
672 err.contains("Password requires a Username option"),
673 "unexpected error: {err}"
674 );
675 }
676
677 #[test]
678 fn weboptions_rejects_timeout_nonpositive() {
679 let err = weboptions_builtin(vec![Value::from("Timeout"), Value::Num(0.0)])
680 .expect_err("timeout should reject nonpositive values");
681 assert!(
682 err.contains("Timeout must be a finite, positive scalar"),
683 "unexpected error: {err}"
684 );
685 }
686
687 #[test]
688 fn weboptions_rejects_headerfields_bad_cell_shape() {
689 let cell = CellArray::new(vec![Value::from("Accept")], 1, 1).expect("cell");
690 let err = weboptions_builtin(vec![Value::from("HeaderFields"), Value::Cell(cell)])
691 .expect_err("headerfields cell shape");
692 assert!(
693 err.contains("HeaderFields cell array must have exactly two columns"),
694 "unexpected error: {err}"
695 );
696 }
697
698 #[test]
699 fn webread_uses_weboptions_without_polluting_query() {
700 let options = weboptions_builtin(Vec::new()).expect("weboptions");
701 let (tx, rx) = mpsc::channel();
702 let url = spawn_server(move |mut stream| {
703 let (headers, _) = read_request(&mut stream);
704 tx.send(headers).unwrap();
705 respond_with(stream, "application/json", br#"{"ok":true}"#);
706 });
707
708 let args = vec![Value::from(url.clone()), options];
709 let result = call_builtin("webread", &args).expect("webread with options");
710 match result {
711 Value::Struct(reply) => {
712 assert!(matches!(reply.fields.get("ok"), Some(Value::Bool(true))));
713 }
714 other => panic!("expected struct response, got {other:?}"),
715 }
716 let headers = rx.recv().expect("captured headers");
717 assert!(headers.starts_with("GET "));
718 assert!(
719 !headers.contains("MediaType=auto"),
720 "MediaType should not appear in query string"
721 );
722 }
723
724 #[test]
725 fn webwrite_uses_weboptions_auto_request_method() {
726 let options = weboptions_builtin(Vec::new()).expect("weboptions default");
727 let payload = Value::from("Hello from RunMat");
728 let (tx, rx) = mpsc::channel();
729 let url = spawn_server(move |mut stream| {
730 let (headers, body) = read_request(&mut stream);
731 tx.send((headers, body)).unwrap();
732 respond_with(stream, "application/json", br#"{"ack":true}"#);
733 });
734
735 let args = vec![Value::from(url), payload, options];
736 let result = call_builtin("webwrite", &args).expect("webwrite with weboptions");
737 match result {
738 Value::Struct(reply) => {
739 assert!(matches!(reply.fields.get("ack"), Some(Value::Bool(true))));
740 }
741 other => panic!("expected struct response, got {other:?}"),
742 }
743 let (headers, body) = rx.recv().expect("request captured");
744 assert!(
745 headers.starts_with("POST "),
746 "expected POST request, got headers: {headers}"
747 );
748 assert!(
749 !body.is_empty(),
750 "expected request body to be present when posting form data"
751 );
752 }
753
754 #[test]
755 #[cfg(feature = "doc_export")]
756 fn doc_examples_present() {
757 let blocks = test_support::doc_examples(DOC_MD);
758 assert!(!blocks.is_empty());
759 }
760}