1use std::collections::VecDeque;
4use std::time::Duration;
5
6use reqwest::blocking::{Client, RequestBuilder};
7use reqwest::header::{HeaderName, HeaderValue, CONTENT_TYPE};
8use reqwest::Url;
9use runmat_builtins::{CellArray, CharArray, StructValue, Tensor, Value};
10use runmat_macros::runtime_builtin;
11
12use crate::builtins::common::spec::{
13 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14 ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::builtins::io::json::jsondecode::decode_json_text;
17use crate::call_builtin;
18use crate::gather_if_needed;
19#[cfg(feature = "doc_export")]
20use crate::register_builtin_doc_text;
21use crate::{register_builtin_fusion_spec, register_builtin_gpu_spec};
22
23const DEFAULT_TIMEOUT_SECONDS: f64 = 60.0;
24const DEFAULT_USER_AGENT: &str = "RunMat webwrite/0.0";
25
26#[cfg(feature = "doc_export")]
27#[allow(clippy::too_many_lines)]
28pub const DOC_MD: &str = r#"---
29title: "webwrite"
30category: "io/http"
31keywords: ["webwrite", "http post", "rest client", "json upload", "form post", "binary upload"]
32summary: "Send data to web services using HTTP POST/PUT requests and return the response."
33references:
34 - https://www.mathworks.com/help/matlab/ref/webwrite.html
35gpu_support:
36 elementwise: false
37 reduction: false
38 precisions: []
39 broadcasting: "none"
40 notes: "webwrite gathers gpuArray inputs and executes entirely on the CPU networking stack."
41fusion:
42 elementwise: false
43 reduction: false
44 max_inputs: 1
45 constants: "inline"
46requires_feature: null
47tested:
48 unit: "builtins::io::http::webwrite::tests"
49 integration:
50 - "builtins::io::http::webwrite::tests::webwrite_posts_form_data_by_default"
51 - "builtins::io::http::webwrite::tests::webwrite_sends_json_when_media_type_json"
52 - "builtins::io::http::webwrite::tests::webwrite_applies_basic_auth_and_custom_headers"
53 - "builtins::io::http::webwrite::tests::webwrite_supports_query_parameters"
54 - "builtins::io::http::webwrite::tests::webwrite_binary_payload_respected"
55---
56
57# What does the `webwrite` function do in MATLAB / RunMat?
58`webwrite` sends data to an HTTP or HTTPS endpoint using methods such as `POST`, `PUT`,
59`PATCH`, or `DELETE`. It mirrors MATLAB behaviour: request bodies are created from MATLAB
60values (structs, cells, strings, numeric tensors), request headers come from `weboptions`
61style arguments, and the response is decoded the same way as `webread`.
62
63## How does the `webwrite` function behave in MATLAB / RunMat?
64- The first input is an absolute URL supplied as a character vector or string scalar.
65- The second input supplies the request body. Structs and two-column cell arrays become
66 `application/x-www-form-urlencoded` payloads. Character vectors / strings are sent as UTF-8
67 text, and other MATLAB values default to JSON encoding via `jsonencode`.
68- Name-value arguments (or an options struct) accept the same fields as MATLAB `weboptions`:
69 `ContentType`, `MediaType`, `Timeout`, `HeaderFields`, `Username`, `Password`,
70 `UserAgent`, `RequestMethod`, and `QueryParameters`.
71- `ContentType` controls how the response is parsed (`"auto"` by default). Set it to `"json"`,
72 `"text"`, or `"binary"` to force JSON decoding, text return, or raw byte vectors.
73- `MediaType` sets the outbound `Content-Type` header. When omitted, RunMat chooses
74 a sensible default (`application/x-www-form-urlencoded`, `application/json`,
75 `text/plain; charset=utf-8`, or `application/octet-stream`) based on the payload.
76- Query parameters can be appended through the `QueryParameters` option or by including
77 additional, unrecognised name-value pairs. Parameter values follow MATLAB scalar rules.
78- HTTP errors, timeouts, TLS verification problems, and JSON encoding issues raise
79 MATLAB-style errors with descriptive text.
80
81## `webwrite` Function GPU Execution Behaviour
82`webwrite` is a sink in the execution graph. Any GPU-resident inputs (for example tensors
83inside structs or cell arrays) are gathered to host memory before encoding the request body.
84Network I/O always runs on the CPU; fusion plans are terminated with
85`ResidencyPolicy::GatherImmediately`.
86
87## Examples of using the `webwrite` function in MATLAB / RunMat
88
89### Posting form fields to a REST endpoint
90```matlab
91payload = struct("name", "Ada", "score", 42);
92opts = struct("ContentType", "json"); % expect JSON response
93reply = webwrite("https://api.example.com/submit", payload, opts);
94disp(reply.status)
95```
96Expected output:
97```matlab
98 "ok"
99```
100
101### Sending JSON payloads
102```matlab
103body = struct("title", "RunMat", "stars", 5);
104opts = struct("MediaType", "application/json", "ContentType", "json");
105resp = webwrite("https://api.example.com/projects", body, opts);
106```
107`resp` is decoded from JSON into structs, doubles, logicals, or strings.
108
109### Uploading plain text
110```matlab
111message = "Hello from RunMat!";
112reply = webwrite("https://api.example.com/echo", message, ...
113 "MediaType", "text/plain", "ContentType", "text");
114```
115`reply` holds the echoed character vector.
116
117### Uploading raw binary data
118```matlab
119bytes = uint8([1 2 3 4 5]);
120webwrite("https://api.example.com/upload", bytes, ...
121 "ContentType", "binary", "MediaType", "application/octet-stream");
122```
123The body is transmitted verbatim as bytes.
124
125### Supplying credentials, custom headers, and query parameters
126```matlab
127headers = struct("X-Client", "RunMat", "Accept", "application/json");
128opts = struct("Username", "ada", "Password", "lovelace", ...
129 "HeaderFields", headers, ...
130 "QueryParameters", struct("verbose", true));
131profile = webwrite("https://api.example.com/me", struct(), opts);
132```
133`profile` contains the decoded JSON profile while the request carries Basic Auth credentials
134and custom headers.
135
136## GPU residency in RunMat (Do I need `gpuArray`?)
137No. `webwrite` executes on the CPU. Any GPU values are automatically gathered before serialising
138the payload, and results are created on the host. Manually gathering is unnecessary.
139
140## FAQ
141
1421. **Which HTTP methods are supported?**
143 `webwrite` defaults to `POST`. Supply `"RequestMethod","put"` (or `"patch"`, `"delete"`) to
144 use other verbs.
145
1462. **How do I send JSON?**
147 Set `"MediaType","application/json"` (optionally via a struct) or `"ContentType","json"`.
148 RunMat serialises the payload with `jsonencode` and sets the appropriate `Content-Type`.
149
1503. **How are form posts encoded?**
151 Struct inputs and two-column cell arrays are turned into
152 `application/x-www-form-urlencoded` bodies. Field values must be scalar text or numbers.
153
1544. **Can I post binary data?**
155 Yes. Provide numeric tensors (`double`, integer, or logical) and set `"ContentType","binary"`
156 or `"MediaType","application/octet-stream"`. Values must be in the 0–255 range.
157
1585. **What controls the response decoding?**
159 `ContentType` mirrors `webread`: `"auto"` inspects response headers, while `"json"`,
160 `"text"`, and `"binary"` force the output format.
161
1626. **How do I add custom headers?**
163 Use `"HeaderFields", struct("Header-Name","value",...)` or a two-column cell array.
164 Header names must be valid HTTP tokens.
165
1667. **Does `webwrite` follow redirects?**
167 Yes. The underlying `reqwest` client follows redirects with the same credentials and headers.
168
1698. **Can I send query parameters and a body simultaneously?**
170 Yes. Provide a `QueryParameters` struct/cell in the options. Parameters are percent-encoded
171 and appended to the URL before the request is issued.
172
1739. **How do timeouts work?**
174 `Timeout` accepts a scalar number of seconds. The default is 60 s. Requests exceeding the
175 limit raise `webwrite: request to <url> timed out`.
176
17710. **What happens with GPU inputs?**
178 They are gathered before serialisation. The function is marked as a sink to break fusion
179 graphs and ensure residency is released.
180
181## See Also
182[webread](./webread), [jsonencode](../json/jsonencode), [jsondecode](../json/jsondecode), [gpuArray](../../acceleration/gpu/gpuArray)
183"#;
184
185pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
186 name: "webwrite",
187 op_kind: GpuOpKind::Custom("http-write"),
188 supported_precisions: &[],
189 broadcast: BroadcastSemantics::None,
190 provider_hooks: &[],
191 constant_strategy: ConstantStrategy::InlineLiteral,
192 residency: ResidencyPolicy::GatherImmediately,
193 nan_mode: ReductionNaN::Include,
194 two_pass_threshold: None,
195 workgroup_size: None,
196 accepts_nan_mode: false,
197 notes: "HTTP uploads run on the CPU and gather gpuArray inputs before serialisation.",
198};
199
200register_builtin_gpu_spec!(GPU_SPEC);
201
202pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
203 name: "webwrite",
204 shape: ShapeRequirements::Any,
205 constant_strategy: ConstantStrategy::InlineLiteral,
206 elementwise: None,
207 reduction: None,
208 emits_nan: false,
209 notes: "webwrite performs network I/O and terminates fusion graphs.",
210};
211
212register_builtin_fusion_spec!(FUSION_SPEC);
213
214#[cfg(feature = "doc_export")]
215register_builtin_doc_text!("webwrite", DOC_MD);
216
217#[runtime_builtin(
218 name = "webwrite",
219 category = "io/http",
220 summary = "Send data to web services using HTTP POST/PUT requests and return the response.",
221 keywords = "webwrite,http post,rest client,json upload,form post",
222 accel = "sink"
223)]
224fn webwrite_builtin(url: Value, rest: Vec<Value>) -> Result<Value, String> {
225 let gathered_url = gather_if_needed(&url).map_err(|e| format!("webwrite: {e}"))?;
226 let url_text = expect_string_scalar(
227 &gathered_url,
228 "webwrite: URL must be a character vector or string scalar",
229 )?;
230 if url_text.trim().is_empty() {
231 return Err("webwrite: URL must not be empty".to_string());
232 }
233 if rest.is_empty() {
234 return Err("webwrite: missing data argument".to_string());
235 }
236
237 let mut gathered = Vec::with_capacity(rest.len());
238 for value in rest {
239 gathered.push(gather_if_needed(&value).map_err(|e| format!("webwrite: {e}"))?);
240 }
241 let mut queue: VecDeque<Value> = VecDeque::from(gathered);
242 let data_value = queue
243 .pop_front()
244 .ok_or_else(|| "webwrite: missing data argument".to_string())?;
245
246 let (options, query_params) = parse_arguments(queue)?;
247 let body = prepare_request_body(data_value, &options)?;
248 execute_request(&url_text, options, &query_params, body)
249}
250
251fn parse_arguments(
252 mut queue: VecDeque<Value>,
253) -> Result<(WebWriteOptions, Vec<(String, String)>), String> {
254 let mut options = WebWriteOptions::default();
255 let mut query_params = Vec::new();
256
257 if matches!(queue.front(), Some(Value::Struct(_))) {
258 if let Some(Value::Struct(struct_value)) = queue.pop_front() {
259 process_struct_fields(&struct_value, &mut options, &mut query_params)?;
260 }
261 } else if matches!(queue.front(), Some(Value::Cell(_))) {
262 if let Some(Value::Cell(cell)) = queue.pop_front() {
263 append_query_from_cell(&cell, &mut query_params)?;
264 }
265 }
266
267 while let Some(name_value) = queue.pop_front() {
268 let name = expect_string_scalar(
269 &name_value,
270 "webwrite: parameter names must be character vectors or strings",
271 )?;
272 let value = queue
273 .pop_front()
274 .ok_or_else(|| "webwrite: missing value for name-value argument".to_string())?;
275 process_name_value_pair(&name, &value, &mut options, &mut query_params)?;
276 }
277
278 Ok((options, query_params))
279}
280
281fn process_struct_fields(
282 struct_value: &StructValue,
283 options: &mut WebWriteOptions,
284 query_params: &mut Vec<(String, String)>,
285) -> Result<(), String> {
286 for (key, value) in &struct_value.fields {
287 process_name_value_pair(key, value, options, query_params)?;
288 }
289 Ok(())
290}
291
292fn process_name_value_pair(
293 name: &str,
294 value: &Value,
295 options: &mut WebWriteOptions,
296 query_params: &mut Vec<(String, String)>,
297) -> Result<(), String> {
298 let lower = name.to_ascii_lowercase();
299 match lower.as_str() {
300 "contenttype" => {
301 let ct = parse_content_type(value)?;
302 options.content_type = ct;
303 Ok(())
304 }
305 "mediatype" => {
306 let media = expect_string_scalar(
307 value,
308 "webwrite: MediaType must be a character vector or string scalar",
309 )?;
310 let trimmed = media.trim();
311 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
312 options.media_type = None;
313 options.request_format = RequestFormat::Auto;
314 options.request_format_explicit = false;
315 } else {
316 options.media_type = Some(media.clone());
317 options.request_format = infer_request_format(&media);
318 options.request_format_explicit = true;
319 }
320 Ok(())
321 }
322 "timeout" => {
323 options.timeout = parse_timeout(value)?;
324 Ok(())
325 }
326 "headerfields" => {
327 let headers = parse_header_fields(value)?;
328 options.headers.extend(headers);
329 Ok(())
330 }
331 "useragent" => {
332 options.user_agent = Some(expect_string_scalar(
333 value,
334 "webwrite: UserAgent must be a character vector or string scalar",
335 )?);
336 Ok(())
337 }
338 "username" => {
339 options.username = Some(expect_string_scalar(
340 value,
341 "webwrite: Username must be a character vector or string scalar",
342 )?);
343 Ok(())
344 }
345 "password" => {
346 options.password = Some(expect_string_scalar(
347 value,
348 "webwrite: Password must be a character vector or string scalar",
349 )?);
350 Ok(())
351 }
352 "requestmethod" => {
353 options.method = parse_request_method(value)?;
354 Ok(())
355 }
356 "queryparameters" => append_query_from_value(value, query_params),
357 _ => {
358 let param_value = value_to_query_string(value, name)?;
359 query_params.push((name.to_string(), param_value));
360 Ok(())
361 }
362 }
363}
364
365fn execute_request(
366 url_text: &str,
367 options: WebWriteOptions,
368 query_params: &[(String, String)],
369 body: PreparedBody,
370) -> Result<Value, String> {
371 let username_present = options
372 .username
373 .as_ref()
374 .map(|s| !s.is_empty())
375 .unwrap_or(false);
376 let password_present = options
377 .password
378 .as_ref()
379 .map(|s| !s.is_empty())
380 .unwrap_or(false);
381 if password_present && !username_present {
382 return Err("webwrite: Password requires a Username option".to_string());
383 }
384
385 let mut url =
386 Url::parse(url_text).map_err(|err| format!("webwrite: invalid URL '{url_text}': {err}"))?;
387 if !query_params.is_empty() {
388 {
389 let mut pairs = url.query_pairs_mut();
390 for (name, value) in query_params {
391 pairs.append_pair(name, value);
392 }
393 }
394 }
395 let url_display = url.to_string();
396
397 let user_agent = options
398 .user_agent
399 .as_deref()
400 .filter(|ua| !ua.trim().is_empty())
401 .unwrap_or(DEFAULT_USER_AGENT);
402
403 let client = Client::builder()
404 .timeout(options.timeout)
405 .user_agent(user_agent)
406 .build()
407 .map_err(|err| format!("webwrite: failed to build HTTP client ({err})"))?;
408
409 let mut builder = match options.method {
410 HttpMethod::Post => client.post(url.clone()),
411 HttpMethod::Put => client.put(url.clone()),
412 HttpMethod::Patch => client.patch(url.clone()),
413 HttpMethod::Delete => client.delete(url.clone()),
414 };
415
416 let has_ct_header = options
417 .headers
418 .iter()
419 .any(|(name, _)| name.eq_ignore_ascii_case("content-type"));
420
421 builder = apply_headers(builder, &options.headers)?;
422 if let Some(username) = &options.username {
423 if !username.is_empty() {
424 let password = options.password.as_ref().filter(|p| !p.is_empty()).cloned();
425 builder = builder.basic_auth(username.clone(), password);
426 }
427 }
428 if !has_ct_header {
429 if let Some(ct) = &body.content_type {
430 builder = builder.header(CONTENT_TYPE, ct.as_str());
431 }
432 }
433 builder = builder.body(body.bytes);
434
435 let response = builder
436 .send()
437 .map_err(|err| request_error("request", &url_display, err))?;
438 let status = response.status();
439 if !status.is_success() {
440 return Err(format!(
441 "webwrite: request to {} failed with HTTP status {}",
442 url_display, status
443 ));
444 }
445
446 let header_content_type = response
447 .headers()
448 .get(CONTENT_TYPE)
449 .and_then(|value| value.to_str().ok())
450 .map(|s| s.to_string());
451 let resolved = options.resolve_content_type(header_content_type.as_deref());
452
453 match resolved {
454 ResolvedContentType::Json => {
455 let body_text = response
456 .text()
457 .map_err(|err| request_error("read response body", &url_display, err))?;
458 match decode_json_text(&body_text) {
459 Ok(value) => Ok(value),
460 Err(err) => Err(map_json_error(err)),
461 }
462 }
463 ResolvedContentType::Text => {
464 let body_text = response
465 .text()
466 .map_err(|err| request_error("read response body", &url_display, err))?;
467 Ok(Value::CharArray(CharArray::new_row(&body_text)))
468 }
469 ResolvedContentType::Binary => {
470 let bytes = response
471 .bytes()
472 .map_err(|err| request_error("read response body", &url_display, err))?;
473 let data: Vec<f64> = bytes.iter().map(|b| f64::from(*b)).collect();
474 let cols = data.len();
475 let tensor =
476 Tensor::new(data, vec![1, cols]).map_err(|err| format!("webwrite: {err}"))?;
477 Ok(Value::Tensor(tensor))
478 }
479 }
480}
481
482fn prepare_request_body(data: Value, options: &WebWriteOptions) -> Result<PreparedBody, String> {
483 let format = match options.request_format {
484 RequestFormat::Auto => guess_request_format(&data),
485 set => set,
486 };
487 let content_type = options
488 .media_type
489 .clone()
490 .or_else(|| default_content_type_for(format));
491 let bytes = match format {
492 RequestFormat::Form => encode_form_payload(&data)?,
493 RequestFormat::Json => encode_json_payload(&data)?,
494 RequestFormat::Text => encode_text_payload(&data)?,
495 RequestFormat::Binary => encode_binary_payload(&data)?,
496 RequestFormat::Auto => encode_json_payload(&data)?,
497 };
498 Ok(PreparedBody {
499 bytes,
500 content_type,
501 })
502}
503
504fn encode_form_payload(value: &Value) -> Result<Vec<u8>, String> {
505 let mut pairs = Vec::new();
506 match value {
507 Value::Struct(struct_value) => {
508 for (key, val) in &struct_value.fields {
509 let text = value_to_query_string(val, key)?;
510 pairs.push((key.clone(), text));
511 }
512 }
513 Value::Cell(cell) => {
514 append_query_from_cell(cell, &mut pairs)?;
515 }
516 Value::CharArray(_)
517 | Value::String(_)
518 | Value::Num(_)
519 | Value::Int(_)
520 | Value::Tensor(_) => {
521 let text = scalar_to_string(value)?;
523 pairs.push(("data".to_string(), text));
524 }
525 _ => {
526 return Err(
527 "webwrite: form payloads must be structs, two-column cell arrays, or scalars"
528 .to_string(),
529 )
530 }
531 }
532
533 let encoded = encode_form_pairs(&pairs);
534 Ok(encoded.into_bytes())
535}
536
537fn encode_form_pairs(pairs: &[(String, String)]) -> String {
538 let mut result = String::new();
539 for (idx, (name, value)) in pairs.iter().enumerate() {
540 if idx > 0 {
541 result.push('&');
542 }
543 result.push_str(&url_encode_component(name));
544 result.push('=');
545 result.push_str(&url_encode_component(value));
546 }
547 result
548}
549
550fn url_encode_component(input: &str) -> String {
551 let mut out = String::new();
552 for byte in input.bytes() {
553 match byte {
554 b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'*' => {
555 out.push(byte as char);
556 }
557 b' ' => out.push('+'),
558 _ => {
559 out.push('%');
560 out.push(hex_digit(byte >> 4));
561 out.push(hex_digit(byte & 0xF));
562 }
563 }
564 }
565 out
566}
567
568fn hex_digit(nibble: u8) -> char {
569 match nibble {
570 0..=9 => (b'0' + nibble) as char,
571 10..=15 => (b'A' + (nibble - 10)) as char,
572 _ => unreachable!(),
573 }
574}
575
576fn encode_json_payload(value: &Value) -> Result<Vec<u8>, String> {
577 let encoded = call_builtin("jsonencode", std::slice::from_ref(value))
578 .map_err(|e| format!("webwrite: {e}"))?;
579 let text = expect_string_scalar(
580 &encoded,
581 "webwrite: jsonencode returned unexpected value; expected text scalar",
582 )?;
583 Ok(text.into_bytes())
584}
585
586fn encode_text_payload(value: &Value) -> Result<Vec<u8>, String> {
587 let text = scalar_to_string(value)?;
588 Ok(text.into_bytes())
589}
590
591fn encode_binary_payload(value: &Value) -> Result<Vec<u8>, String> {
592 match value {
593 Value::Tensor(tensor) => tensor_f64_to_bytes(tensor),
594 Value::Num(n) => Ok(vec![float_to_byte(*n)?]),
595 Value::Int(i) => Ok(vec![int_to_byte(i.to_i64())?]),
596 Value::Bool(b) => Ok(vec![if *b { 1 } else { 0 }]),
597 Value::LogicalArray(array) => Ok(array.data.clone()),
598 Value::CharArray(ca) => {
599 let mut bytes = Vec::with_capacity(ca.data.len());
600 for ch in &ca.data {
601 let code = *ch as u32;
602 if code > 0xFF {
603 return Err("webwrite: character codes exceed 255 for binary payload".into());
604 }
605 bytes.push(code as u8);
606 }
607 Ok(bytes)
608 }
609 Value::String(s) => Ok(s.as_bytes().to_vec()),
610 Value::StringArray(sa) => {
611 if sa.data.len() == 1 {
612 Ok(sa.data[0].as_bytes().to_vec())
613 } else {
614 Err("webwrite: binary payload string arrays must be scalar".to_string())
615 }
616 }
617 _ => Err("webwrite: unsupported value for binary payload".to_string()),
618 }
619}
620
621fn tensor_f64_to_bytes(tensor: &Tensor) -> Result<Vec<u8>, String> {
622 let mut bytes = Vec::with_capacity(tensor.data.len());
623 for value in &tensor.data {
624 bytes.push(float_to_byte(*value)?);
625 }
626 Ok(bytes)
627}
628
629fn float_to_byte(value: f64) -> Result<u8, String> {
630 if !value.is_finite() {
631 return Err("webwrite: binary payload values must be finite".to_string());
632 }
633 let rounded = value.round();
634 if (value - rounded).abs() > 1e-9 {
635 return Err("webwrite: binary payload values must be integers in 0..255".to_string());
636 }
637 let int_val = rounded as i64;
638 int_to_byte(int_val)
639}
640
641fn int_to_byte(value: i64) -> Result<u8, String> {
642 if !(0..=255).contains(&value) {
643 return Err("webwrite: binary payload values must be in the range 0..255".to_string());
644 }
645 Ok(value as u8)
646}
647
648fn append_query_from_value(
649 value: &Value,
650 query_params: &mut Vec<(String, String)>,
651) -> Result<(), String> {
652 match value {
653 Value::Struct(struct_value) => {
654 for (key, val) in &struct_value.fields {
655 let text = value_to_query_string(val, key)?;
656 query_params.push((key.clone(), text));
657 }
658 Ok(())
659 }
660 Value::Cell(cell) => append_query_from_cell(cell, query_params),
661 _ => Err("webwrite: QueryParameters must be a struct or cell array".to_string()),
662 }
663}
664
665fn append_query_from_cell(
666 cell: &CellArray,
667 query_params: &mut Vec<(String, String)>,
668) -> Result<(), String> {
669 if cell.cols != 2 {
670 return Err("webwrite: cell array of query parameters must have two columns".to_string());
671 }
672 for row in 0..cell.rows {
673 let name_value = cell.get(row, 0).map_err(|e| format!("webwrite: {e}"))?;
674 let value_value = cell.get(row, 1).map_err(|e| format!("webwrite: {e}"))?;
675 let name = expect_string_scalar(
676 &name_value,
677 "webwrite: query parameter names must be text scalars",
678 )?;
679 let text = value_to_query_string(&value_value, &name)?;
680 query_params.push((name, text));
681 }
682 Ok(())
683}
684
685fn parse_content_type(value: &Value) -> Result<ContentTypeHint, String> {
686 let text = expect_string_scalar(
687 value,
688 "webwrite: ContentType must be a character vector or string scalar",
689 )?;
690 let lower = text.trim().to_ascii_lowercase();
691 match lower.as_str() {
692 "auto" => Ok(ContentTypeHint::Auto),
693 "json" => Ok(ContentTypeHint::Json),
694 "text" => Ok(ContentTypeHint::Text),
695 "binary" => Ok(ContentTypeHint::Binary),
696 _ => Err("webwrite: ContentType must be 'auto', 'json', 'text', or 'binary'".to_string()),
697 }
698}
699
700fn parse_timeout(value: &Value) -> Result<Duration, String> {
701 let seconds = numeric_scalar(
702 value,
703 "webwrite: Timeout must be a finite, non-negative scalar numeric value",
704 )?;
705 if !seconds.is_finite() || seconds < 0.0 {
706 return Err(
707 "webwrite: Timeout must be a finite, non-negative scalar numeric value".to_string(),
708 );
709 }
710 Ok(Duration::from_secs_f64(seconds))
711}
712
713fn parse_request_method(value: &Value) -> Result<HttpMethod, String> {
714 let text = expect_string_scalar(
715 value,
716 "webwrite: RequestMethod must be a character vector or string scalar",
717 )?;
718 match text.trim().to_ascii_lowercase().as_str() {
719 "auto" => Ok(HttpMethod::Post),
720 "post" => Ok(HttpMethod::Post),
721 "put" => Ok(HttpMethod::Put),
722 "patch" => Ok(HttpMethod::Patch),
723 "delete" => Ok(HttpMethod::Delete),
724 other => Err(format!(
725 "webwrite: unsupported RequestMethod '{}'; expected auto, post, put, patch, or delete",
726 other
727 )),
728 }
729}
730
731fn parse_header_fields(value: &Value) -> Result<Vec<(String, String)>, String> {
732 match value {
733 Value::Struct(struct_value) => {
734 let mut headers = Vec::with_capacity(struct_value.fields.len());
735 for (key, val) in &struct_value.fields {
736 let header_value = expect_string_scalar(
737 val,
738 "webwrite: header values must be character vectors or string scalars",
739 )?;
740 headers.push((key.clone(), header_value));
741 }
742 Ok(headers)
743 }
744 Value::Cell(cell) => {
745 if cell.cols != 2 {
746 return Err(
747 "webwrite: HeaderFields cell array must have exactly two columns".to_string(),
748 );
749 }
750 let mut headers = Vec::with_capacity(cell.rows);
751 for row in 0..cell.rows {
752 let name = cell.get(row, 0).map_err(|e| format!("webwrite: {e}"))?;
753 let value = cell.get(row, 1).map_err(|e| format!("webwrite: {e}"))?;
754 let header_name = expect_string_scalar(
755 &name,
756 "webwrite: header names must be character vectors or string scalars",
757 )?;
758 if header_name.trim().is_empty() {
759 return Err("webwrite: header names must not be empty".to_string());
760 }
761 let header_value = expect_string_scalar(
762 &value,
763 "webwrite: header values must be character vectors or string scalars",
764 )?;
765 headers.push((header_name, header_value));
766 }
767 Ok(headers)
768 }
769 _ => Err("webwrite: HeaderFields must be a struct or two-column cell array".to_string()),
770 }
771}
772
773fn request_error(action: &str, url: &str, err: reqwest::Error) -> String {
774 if err.is_timeout() {
775 format!("webwrite: {action} to {url} timed out")
776 } else if err.is_connect() {
777 format!("webwrite: unable to connect to {url}: {err}")
778 } else if err.is_status() {
779 format!("webwrite: HTTP error for {url}: {err}")
780 } else {
781 format!("webwrite: failed to {action} {url}: {err}")
782 }
783}
784
785fn map_json_error(err: String) -> String {
786 if let Some(rest) = err.strip_prefix("jsondecode: ") {
787 format!("webwrite: failed to parse JSON response ({rest})")
788 } else {
789 format!("webwrite: failed to parse JSON response ({err})")
790 }
791}
792
793fn apply_headers(
794 mut builder: RequestBuilder,
795 headers: &[(String, String)],
796) -> Result<RequestBuilder, String> {
797 for (name, value) in headers {
798 if name.trim().is_empty() {
799 return Err("webwrite: header names must not be empty".to_string());
800 }
801 let header_name = HeaderName::from_bytes(name.as_bytes())
802 .map_err(|_| format!("webwrite: invalid header name '{name}'"))?;
803 let header_value = HeaderValue::from_str(value)
804 .map_err(|_| format!("webwrite: invalid header value for '{name}'"))?;
805 builder = builder.header(header_name, header_value);
806 }
807 Ok(builder)
808}
809
810fn numeric_scalar(value: &Value, context: &str) -> Result<f64, String> {
811 match value {
812 Value::Num(n) => Ok(*n),
813 Value::Int(i) => Ok(i.to_f64()),
814 Value::Tensor(tensor) => {
815 if tensor.data.len() == 1 {
816 Ok(tensor.data[0])
817 } else {
818 Err(context.to_string())
819 }
820 }
821 _ => Err(context.to_string()),
822 }
823}
824
825fn scalar_to_string(value: &Value) -> Result<String, String> {
826 match value {
827 Value::String(s) => Ok(s.clone()),
828 Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
829 Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
830 Value::Num(n) => Ok(format!("{}", n)),
831 Value::Int(i) => Ok(i.to_i64().to_string()),
832 Value::Bool(b) => Ok(if *b { "true".into() } else { "false".into() }),
833 Value::Tensor(tensor) => {
834 if tensor.data.len() == 1 {
835 Ok(format!("{}", tensor.data[0]))
836 } else {
837 Err("webwrite: expected scalar value for text payload".to_string())
838 }
839 }
840 Value::LogicalArray(array) => {
841 if array.len() == 1 {
842 Ok(if array.data[0] != 0 {
843 "true".into()
844 } else {
845 "false".into()
846 })
847 } else {
848 Err("webwrite: expected scalar value for text payload".to_string())
849 }
850 }
851 _ => Err("webwrite: unsupported value type for text payload".to_string()),
852 }
853}
854
855fn expect_string_scalar(value: &Value, context: &str) -> Result<String, String> {
856 match value {
857 Value::String(s) => Ok(s.clone()),
858 Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
859 Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
860 _ => Err(context.to_string()),
861 }
862}
863
864fn value_to_query_string(value: &Value, name: &str) -> Result<String, String> {
865 match value {
866 Value::String(s) => Ok(s.clone()),
867 Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
868 Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
869 Value::Num(n) => Ok(format!("{}", n)),
870 Value::Int(i) => Ok(i.to_i64().to_string()),
871 Value::Bool(b) => Ok(if *b { "true".into() } else { "false".into() }),
872 Value::Tensor(tensor) => {
873 if tensor.data.len() == 1 {
874 Ok(format!("{}", tensor.data[0]))
875 } else {
876 Err(format!(
877 "webwrite: query parameter '{}' must be scalar",
878 name
879 ))
880 }
881 }
882 Value::LogicalArray(array) => {
883 if array.len() == 1 {
884 Ok(if array.data[0] != 0 {
885 "true".into()
886 } else {
887 "false".into()
888 })
889 } else {
890 Err(format!(
891 "webwrite: query parameter '{}' must be scalar",
892 name
893 ))
894 }
895 }
896 _ => Err(format!(
897 "webwrite: unsupported value type for query parameter '{}'",
898 name
899 )),
900 }
901}
902
903fn guess_request_format(value: &Value) -> RequestFormat {
904 match value {
905 Value::Struct(_) => RequestFormat::Form,
906 Value::Cell(cell) if cell.cols == 2 => RequestFormat::Form,
907 Value::CharArray(ca) if ca.rows == 1 => RequestFormat::Text,
908 Value::String(_) => RequestFormat::Text,
909 Value::StringArray(sa) => {
910 if sa.data.len() == 1 {
911 RequestFormat::Text
912 } else {
913 RequestFormat::Json
914 }
915 }
916 Value::Tensor(_) | Value::LogicalArray(_) => RequestFormat::Json,
917 Value::Num(_) | Value::Int(_) | Value::Bool(_) => RequestFormat::Json,
918 _ => RequestFormat::Json,
919 }
920}
921
922fn infer_request_format(media_type: &str) -> RequestFormat {
923 let lower = media_type.trim().to_ascii_lowercase();
924 if lower.contains("json") {
925 RequestFormat::Json
926 } else if lower.starts_with("text/") || lower.contains("xml") {
927 RequestFormat::Text
928 } else if lower == "application/x-www-form-urlencoded" {
929 RequestFormat::Form
930 } else {
931 RequestFormat::Binary
932 }
933}
934
935fn default_content_type_for(format: RequestFormat) -> Option<String> {
936 match format {
937 RequestFormat::Form => Some("application/x-www-form-urlencoded".to_string()),
938 RequestFormat::Json => Some("application/json".to_string()),
939 RequestFormat::Text => Some("text/plain; charset=utf-8".to_string()),
940 RequestFormat::Binary => Some("application/octet-stream".to_string()),
941 RequestFormat::Auto => None,
942 }
943}
944
945#[derive(Clone, Debug)]
946struct PreparedBody {
947 bytes: Vec<u8>,
948 content_type: Option<String>,
949}
950
951#[derive(Clone, Copy, Debug)]
952enum ContentTypeHint {
953 Auto,
954 Text,
955 Json,
956 Binary,
957}
958
959#[derive(Clone, Copy, Debug)]
960enum ResolvedContentType {
961 Text,
962 Json,
963 Binary,
964}
965
966#[derive(Clone, Copy, Debug)]
967enum RequestFormat {
968 Auto,
969 Form,
970 Json,
971 Text,
972 Binary,
973}
974
975#[derive(Clone, Copy, Debug)]
976enum HttpMethod {
977 Post,
978 Put,
979 Patch,
980 Delete,
981}
982
983#[derive(Clone, Debug)]
984struct WebWriteOptions {
985 content_type: ContentTypeHint,
986 timeout: Duration,
987 headers: Vec<(String, String)>,
988 user_agent: Option<String>,
989 username: Option<String>,
990 password: Option<String>,
991 method: HttpMethod,
992 request_format: RequestFormat,
993 request_format_explicit: bool,
994 media_type: Option<String>,
995}
996
997impl Default for WebWriteOptions {
998 fn default() -> Self {
999 Self {
1000 content_type: ContentTypeHint::Auto,
1001 timeout: Duration::from_secs_f64(DEFAULT_TIMEOUT_SECONDS),
1002 headers: Vec::new(),
1003 user_agent: None,
1004 username: None,
1005 password: None,
1006 method: HttpMethod::Post,
1007 request_format: RequestFormat::Auto,
1008 request_format_explicit: false,
1009 media_type: None,
1010 }
1011 }
1012}
1013
1014impl WebWriteOptions {
1015 fn resolve_content_type(&self, header: Option<&str>) -> ResolvedContentType {
1016 match self.content_type {
1017 ContentTypeHint::Json => ResolvedContentType::Json,
1018 ContentTypeHint::Text => ResolvedContentType::Text,
1019 ContentTypeHint::Binary => ResolvedContentType::Binary,
1020 ContentTypeHint::Auto => infer_response_content_type(header),
1021 }
1022 }
1023}
1024
1025fn infer_response_content_type(header: Option<&str>) -> ResolvedContentType {
1026 if let Some(raw) = header {
1027 let mime = raw
1028 .split(';')
1029 .next()
1030 .map(|part| part.trim().to_ascii_lowercase())
1031 .unwrap_or_default();
1032 if mime == "application/json" || mime == "text/json" || mime.ends_with("+json") {
1033 ResolvedContentType::Json
1034 } else if mime.starts_with("text/")
1035 || mime == "application/xml"
1036 || mime.ends_with("+xml")
1037 || mime == "application/xhtml+xml"
1038 || mime == "application/javascript"
1039 || mime == "application/x-www-form-urlencoded"
1040 {
1041 ResolvedContentType::Text
1042 } else {
1043 ResolvedContentType::Binary
1044 }
1045 } else {
1046 ResolvedContentType::Text
1047 }
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052 use super::*;
1053 use std::io::{Read, Write};
1054 use std::net::{TcpListener, TcpStream};
1055 use std::sync::mpsc;
1056 use std::thread;
1057
1058 #[cfg(feature = "doc_export")]
1059 use crate::builtins::common::test_support;
1060
1061 fn spawn_server<F>(handler: F) -> String
1062 where
1063 F: FnOnce(TcpStream) + Send + 'static,
1064 {
1065 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
1066 let addr = listener.local_addr().unwrap();
1067 thread::spawn(move || {
1068 if let Ok((stream, _)) = listener.accept() {
1069 handler(stream);
1070 }
1071 });
1072 format!("http://{}", addr)
1073 }
1074
1075 fn read_request(stream: &mut TcpStream) -> (String, Vec<u8>) {
1076 let mut buffer = Vec::new();
1077 let mut tmp = [0u8; 512];
1078 let mut header_end = None;
1079 loop {
1080 match stream.read(&mut tmp) {
1081 Ok(0) => break,
1082 Ok(n) => {
1083 buffer.extend_from_slice(&tmp[..n]);
1084 if let Some(idx) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
1085 header_end = Some(idx + 4);
1086 break;
1087 }
1088 }
1089 Err(_) => break,
1090 }
1091 }
1092 let header_end = header_end.unwrap_or(buffer.len());
1093 let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
1094 let content_length = headers
1095 .lines()
1096 .find_map(|line| {
1097 let mut parts = line.splitn(2, ':');
1098 let name = parts.next()?.trim();
1099 let value = parts.next()?.trim();
1100 if name.eq_ignore_ascii_case("content-length") {
1101 value.parse::<usize>().ok()
1102 } else {
1103 None
1104 }
1105 })
1106 .unwrap_or(0);
1107 let mut body = buffer[header_end..].to_vec();
1108 while body.len() < content_length {
1109 match stream.read(&mut tmp) {
1110 Ok(0) => break,
1111 Ok(n) => body.extend_from_slice(&tmp[..n]),
1112 Err(_) => break,
1113 }
1114 }
1115 (headers, body)
1116 }
1117
1118 fn respond_with(mut stream: TcpStream, content_type: &str, body: &[u8]) {
1119 let response = format!(
1120 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n",
1121 body.len(),
1122 content_type
1123 );
1124 let _ = stream.write_all(response.as_bytes());
1125 let _ = stream.write_all(body);
1126 }
1127
1128 #[test]
1129 fn webwrite_posts_form_data_by_default() {
1130 let payload = {
1131 let mut st = StructValue::new();
1132 st.fields.insert("name".to_string(), Value::from("Ada"));
1133 st.fields.insert("score".to_string(), Value::Num(42.0));
1134 st
1135 };
1136 let opts = {
1137 let mut st = StructValue::new();
1138 st.fields
1139 .insert("ContentType".to_string(), Value::from("json"));
1140 st
1141 };
1142
1143 let (tx, rx) = mpsc::channel();
1144 let url = spawn_server(move |mut stream| {
1145 let (headers, body) = read_request(&mut stream);
1146 tx.send((headers, body)).unwrap();
1147 respond_with(
1148 stream,
1149 "application/json",
1150 br#"{"status":"ok","received":true}"#,
1151 );
1152 });
1153
1154 let result = webwrite_builtin(
1155 Value::from(url),
1156 vec![Value::Struct(payload), Value::Struct(opts)],
1157 )
1158 .expect("webwrite");
1159
1160 let (headers, body) = rx.recv().expect("request captured");
1161 assert!(headers.starts_with("POST "));
1162 let headers_lower = headers.to_ascii_lowercase();
1163 assert!(headers_lower.contains("content-type: application/x-www-form-urlencoded"));
1164 let body_text = String::from_utf8(body).expect("utf8 body");
1165 assert!(body_text.contains("name=Ada"));
1166 assert!(body_text.contains("score=42"));
1167
1168 match result {
1169 Value::Struct(reply) => {
1170 assert!(matches!(
1171 reply.fields.get("received"),
1172 Some(Value::Bool(true))
1173 ));
1174 }
1175 other => panic!("expected struct response, got {other:?}"),
1176 }
1177 }
1178
1179 #[test]
1180 fn webwrite_sends_json_when_media_type_json() {
1181 let payload = {
1182 let mut st = StructValue::new();
1183 st.fields.insert("title".to_string(), Value::from("RunMat"));
1184 st.fields.insert("stars".to_string(), Value::Num(5.0));
1185 st
1186 };
1187 let opts = {
1188 let mut st = StructValue::new();
1189 st.fields.insert(
1190 "MediaType".to_string(),
1191 Value::from("application/json; charset=utf-8"),
1192 );
1193 st.fields
1194 .insert("ContentType".to_string(), Value::from("json"));
1195 st
1196 };
1197
1198 let (tx, rx) = mpsc::channel();
1199 let url = spawn_server(move |mut stream| {
1200 let (headers, body) = read_request(&mut stream);
1201 tx.send((headers, body)).unwrap();
1202 respond_with(stream, "application/json", br#"{"ok":true}"#);
1203 });
1204
1205 let result = webwrite_builtin(
1206 Value::from(url),
1207 vec![Value::Struct(payload), Value::Struct(opts)],
1208 )
1209 .expect("webwrite");
1210
1211 let (headers, body) = rx.recv().expect("request");
1212 let headers_lower = headers.to_ascii_lowercase();
1213 assert!(headers_lower.contains("content-type: application/json"));
1214 let body_text = String::from_utf8(body).expect("utf8 body");
1215 assert!(body_text.contains("\"title\":\"RunMat\""));
1216 assert!(body_text.contains("\"stars\":5"));
1217
1218 match result {
1219 Value::Struct(reply) => {
1220 assert!(matches!(reply.fields.get("ok"), Some(Value::Bool(true))));
1221 }
1222 other => panic!("expected struct response, got {other:?}"),
1223 }
1224 }
1225
1226 #[test]
1227 fn webwrite_applies_basic_auth_and_custom_headers() {
1228 let payload = Value::from("");
1229 let mut header_struct = StructValue::new();
1230 header_struct
1231 .fields
1232 .insert("X-Test".to_string(), Value::from("yes"));
1233 header_struct
1234 .fields
1235 .insert("Accept".to_string(), Value::from("text/plain"));
1236 let mut opts_struct = StructValue::new();
1237 opts_struct
1238 .fields
1239 .insert("Username".to_string(), Value::from("ada"));
1240 opts_struct
1241 .fields
1242 .insert("Password".to_string(), Value::from("secret"));
1243 opts_struct
1244 .fields
1245 .insert("HeaderFields".to_string(), Value::Struct(header_struct));
1246 opts_struct
1247 .fields
1248 .insert("ContentType".to_string(), Value::from("text"));
1249 opts_struct
1250 .fields
1251 .insert("MediaType".to_string(), Value::from("text/plain"));
1252
1253 let (tx, rx) = mpsc::channel();
1254 let url = spawn_server(move |mut stream| {
1255 let (headers, _) = read_request(&mut stream);
1256 tx.send(headers).unwrap();
1257 respond_with(stream, "text/plain", b"OK");
1258 });
1259
1260 let result = webwrite_builtin(Value::from(url), vec![payload, Value::Struct(opts_struct)])
1261 .expect("webwrite");
1262
1263 let headers = rx.recv().expect("headers");
1264 let headers_lower = headers.to_ascii_lowercase();
1265 assert!(headers_lower.contains("authorization: basic"));
1266 assert!(headers_lower.contains("x-test: yes"));
1267 assert!(headers_lower.contains("accept: text/plain"));
1268
1269 match result {
1270 Value::CharArray(ca) => {
1271 let text: String = ca.data.iter().collect();
1272 assert_eq!(text, "OK");
1273 }
1274 other => panic!("expected char array, got {other:?}"),
1275 }
1276 }
1277
1278 #[test]
1279 fn webwrite_supports_query_parameters() {
1280 let payload = Value::Struct(StructValue::new());
1281 let mut qp_struct = StructValue::new();
1282 qp_struct.fields.insert("page".to_string(), Value::Num(2.0));
1283 qp_struct
1284 .fields
1285 .insert("verbose".to_string(), Value::Bool(true));
1286 let mut opts_struct = StructValue::new();
1287 opts_struct
1288 .fields
1289 .insert("QueryParameters".to_string(), Value::Struct(qp_struct));
1290
1291 let (tx, rx) = mpsc::channel();
1292 let url = spawn_server(move |mut stream| {
1293 let (headers, _) = read_request(&mut stream);
1294 tx.send(headers).unwrap();
1295 respond_with(stream, "application/json", br#"{"ok":true}"#);
1296 });
1297
1298 let _ = webwrite_builtin(
1299 Value::from(url.clone()),
1300 vec![payload, Value::Struct(opts_struct)],
1301 )
1302 .expect("webwrite");
1303
1304 let headers = rx.recv().expect("headers");
1305 let first_line = headers.lines().next().unwrap_or("");
1306 assert!(first_line.starts_with("POST "));
1307 assert!(first_line.contains("page=2"));
1308 assert!(first_line.contains("verbose=true"));
1309 }
1310
1311 #[test]
1312 fn webwrite_binary_payload_respected() {
1313 let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 255.0], vec![4, 1]).unwrap();
1314 let payload = Value::Tensor(tensor);
1315 let mut opts_struct = StructValue::new();
1316 opts_struct
1317 .fields
1318 .insert("ContentType".to_string(), Value::from("binary"));
1319 opts_struct.fields.insert(
1320 "MediaType".to_string(),
1321 Value::from("application/octet-stream"),
1322 );
1323
1324 let (tx, rx) = mpsc::channel();
1325 let url = spawn_server(move |mut stream| {
1326 let (headers, body) = read_request(&mut stream);
1327 tx.send((headers, body)).unwrap();
1328 respond_with(stream, "text/plain", b"OK");
1329 });
1330
1331 let _ = webwrite_builtin(Value::from(url), vec![payload, Value::Struct(opts_struct)])
1332 .expect("webwrite");
1333
1334 let (headers, body) = rx.recv().expect("request");
1335 let headers_lower = headers.to_ascii_lowercase();
1336 assert!(headers_lower.contains("content-type: application/octet-stream"));
1337 assert_eq!(body, vec![1, 2, 3, 255]);
1338 }
1339
1340 #[test]
1341 #[cfg(feature = "doc_export")]
1342 fn doc_examples_present() {
1343 let blocks = test_support::doc_examples(DOC_MD);
1344 assert!(!blocks.is_empty());
1345 }
1346}