runmat_runtime/builtins/io/http/
webwrite.rs

1//! MATLAB-compatible `webwrite` builtin for HTTP/HTTPS uploads.
2
3use 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            // Allow scalar text/numeric by mapping to a default "data" key.
522            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}