Skip to main content

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 base64::engine::general_purpose::STANDARD as BASE64_ENGINE;
7use base64::Engine;
8use runmat_builtins::{
9    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
10    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
11    CellArray, CharArray, StructValue, Tensor, Value,
12};
13use runmat_macros::runtime_builtin;
14use url::Url;
15
16use super::transport::{
17    self, decode_body_as_text, header_value, HttpMethod, HttpRequest, HEADER_CONTENT_TYPE,
18};
19use crate::builtins::common::spec::{
20    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
21    ReductionNaN, ResidencyPolicy, ShapeRequirements,
22};
23use crate::builtins::io::json::jsondecode::decode_json_text;
24use crate::call_builtin_async;
25use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
26
27const DEFAULT_TIMEOUT_SECONDS: f64 = 60.0;
28const DEFAULT_USER_AGENT: &str = "RunMat webwrite/0.0";
29const BUILTIN_NAME: &str = "webwrite";
30
31const WEBWRITE_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
32    name: "response",
33    ty: BuiltinParamType::Any,
34    arity: BuiltinParamArity::Required,
35    default: None,
36    description: "Decoded response payload from the remote endpoint.",
37}];
38const WEBWRITE_INPUTS_URL_DATA: [BuiltinParamDescriptor; 2] = [
39    BuiltinParamDescriptor {
40        name: "url",
41        ty: BuiltinParamType::StringScalar,
42        arity: BuiltinParamArity::Required,
43        default: None,
44        description: "HTTP/HTTPS URL target.",
45    },
46    BuiltinParamDescriptor {
47        name: "data",
48        ty: BuiltinParamType::Any,
49        arity: BuiltinParamArity::Required,
50        default: None,
51        description: "Request payload value.",
52    },
53];
54const WEBWRITE_INPUTS_URL_DATA_OPTIONS: [BuiltinParamDescriptor; 3] = [
55    BuiltinParamDescriptor {
56        name: "url",
57        ty: BuiltinParamType::StringScalar,
58        arity: BuiltinParamArity::Required,
59        default: None,
60        description: "HTTP/HTTPS URL target.",
61    },
62    BuiltinParamDescriptor {
63        name: "data",
64        ty: BuiltinParamType::Any,
65        arity: BuiltinParamArity::Required,
66        default: None,
67        description: "Request payload value.",
68    },
69    BuiltinParamDescriptor {
70        name: "optionsStruct",
71        ty: BuiltinParamType::Any,
72        arity: BuiltinParamArity::Required,
73        default: None,
74        description: "weboptions struct or option struct literal.",
75    },
76];
77const WEBWRITE_INPUTS_URL_DATA_NAME_VALUE: [BuiltinParamDescriptor; 4] = [
78    BuiltinParamDescriptor {
79        name: "url",
80        ty: BuiltinParamType::StringScalar,
81        arity: BuiltinParamArity::Required,
82        default: None,
83        description: "HTTP/HTTPS URL target.",
84    },
85    BuiltinParamDescriptor {
86        name: "data",
87        ty: BuiltinParamType::Any,
88        arity: BuiltinParamArity::Required,
89        default: None,
90        description: "Request payload value.",
91    },
92    BuiltinParamDescriptor {
93        name: "name",
94        ty: BuiltinParamType::StringScalar,
95        arity: BuiltinParamArity::Variadic,
96        default: None,
97        description: "Option or query parameter name.",
98    },
99    BuiltinParamDescriptor {
100        name: "value",
101        ty: BuiltinParamType::Any,
102        arity: BuiltinParamArity::Variadic,
103        default: None,
104        description: "Option or query parameter value.",
105    },
106];
107const WEBWRITE_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
108    BuiltinSignatureDescriptor {
109        label: "response = webwrite(url, data)",
110        inputs: &WEBWRITE_INPUTS_URL_DATA,
111        outputs: &WEBWRITE_OUTPUT,
112    },
113    BuiltinSignatureDescriptor {
114        label: "response = webwrite(url, data, optionsStruct)",
115        inputs: &WEBWRITE_INPUTS_URL_DATA_OPTIONS,
116        outputs: &WEBWRITE_OUTPUT,
117    },
118    BuiltinSignatureDescriptor {
119        label: "response = webwrite(url, data, name, value, ...)",
120        inputs: &WEBWRITE_INPUTS_URL_DATA_NAME_VALUE,
121        outputs: &WEBWRITE_OUTPUT,
122    },
123    BuiltinSignatureDescriptor {
124        label: "response = webwrite(url, data, optionsStruct, name, value, ...)",
125        inputs: &WEBWRITE_INPUTS_URL_DATA_NAME_VALUE,
126        outputs: &WEBWRITE_OUTPUT,
127    },
128];
129
130const WEBWRITE_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
131    code: "RM.WEBWRITE.INVALID_ARGUMENT",
132    identifier: Some("RunMat:webwrite:InvalidArgument"),
133    when: "Argument type/shape does not match webwrite call contract.",
134    message: "webwrite: invalid argument",
135};
136const WEBWRITE_ERROR_INVALID_URL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
137    code: "RM.WEBWRITE.INVALID_URL",
138    identifier: Some("RunMat:webwrite:InvalidUrl"),
139    when: "URL is empty or malformed.",
140    message: "webwrite: invalid URL",
141};
142const WEBWRITE_ERROR_MISSING_DATA: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
143    code: "RM.WEBWRITE.MISSING_DATA",
144    identifier: Some("RunMat:webwrite:MissingData"),
145    when: "Required data argument is missing.",
146    message: "webwrite: missing data argument",
147};
148const WEBWRITE_ERROR_MISSING_OPTION_VALUE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
149    code: "RM.WEBWRITE.MISSING_OPTION_VALUE",
150    identifier: Some("RunMat:webwrite:MissingOptionValue"),
151    when: "A name-value option key has no value.",
152    message: "webwrite: missing option value",
153};
154const WEBWRITE_ERROR_INVALID_OPTION_VALUE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
155    code: "RM.WEBWRITE.INVALID_OPTION_VALUE",
156    identifier: Some("RunMat:webwrite:InvalidOptionValue"),
157    when: "An option value fails validation.",
158    message: "webwrite: invalid option value",
159};
160const WEBWRITE_ERROR_INVALID_CREDENTIALS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
161    code: "RM.WEBWRITE.INVALID_CREDENTIALS",
162    identifier: Some("RunMat:webwrite:InvalidCredentials"),
163    when: "Password is provided without username.",
164    message: "webwrite: invalid credentials",
165};
166const WEBWRITE_ERROR_TRANSPORT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
167    code: "RM.WEBWRITE.TRANSPORT",
168    identifier: Some("RunMat:webwrite:Transport"),
169    when: "HTTP transport fails.",
170    message: "webwrite: transport failure",
171};
172const WEBWRITE_ERROR_RESPONSE_JSON: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
173    code: "RM.WEBWRITE.RESPONSE_JSON",
174    identifier: Some("RunMat:webwrite:ResponseJson"),
175    when: "Response body cannot be decoded as JSON.",
176    message: "webwrite: failed to parse JSON response",
177};
178const WEBWRITE_ERROR_OUTPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
179    code: "RM.WEBWRITE.OUTPUT",
180    identifier: Some("RunMat:webwrite:Output"),
181    when: "Output payload cannot be materialized.",
182    message: "webwrite: output materialization failure",
183};
184const WEBWRITE_ERROR_FLOW: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
185    code: "RM.WEBWRITE.FLOW",
186    identifier: Some("RunMat:webwrite:Flow"),
187    when: "Nested flow fails while gathering inputs or nested builtin calls.",
188    message: "webwrite: flow failure",
189};
190
191const WEBWRITE_ERRORS: [BuiltinErrorDescriptor; 10] = [
192    WEBWRITE_ERROR_INVALID_ARGUMENT,
193    WEBWRITE_ERROR_INVALID_URL,
194    WEBWRITE_ERROR_MISSING_DATA,
195    WEBWRITE_ERROR_MISSING_OPTION_VALUE,
196    WEBWRITE_ERROR_INVALID_OPTION_VALUE,
197    WEBWRITE_ERROR_INVALID_CREDENTIALS,
198    WEBWRITE_ERROR_TRANSPORT,
199    WEBWRITE_ERROR_RESPONSE_JSON,
200    WEBWRITE_ERROR_OUTPUT,
201    WEBWRITE_ERROR_FLOW,
202];
203
204pub const WEBWRITE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
205    signatures: &WEBWRITE_SIGNATURES,
206    output_mode: BuiltinOutputMode::Fixed,
207    completion_policy: BuiltinCompletionPolicy::Public,
208    errors: &WEBWRITE_ERRORS,
209};
210
211#[allow(clippy::too_many_lines)]
212#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::http::webwrite")]
213pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
214    name: "webwrite",
215    op_kind: GpuOpKind::Custom("http-write"),
216    supported_precisions: &[],
217    broadcast: BroadcastSemantics::None,
218    provider_hooks: &[],
219    constant_strategy: ConstantStrategy::InlineLiteral,
220    residency: ResidencyPolicy::GatherImmediately,
221    nan_mode: ReductionNaN::Include,
222    two_pass_threshold: None,
223    workgroup_size: None,
224    accepts_nan_mode: false,
225    notes: "HTTP uploads run on the CPU and gather gpuArray inputs before serialisation.",
226};
227
228fn webwrite_error(message: impl Into<String>) -> RuntimeError {
229    webwrite_error_with(&WEBWRITE_ERROR_INVALID_ARGUMENT, message)
230}
231
232fn webwrite_error_with(
233    error: &'static BuiltinErrorDescriptor,
234    message: impl Into<String>,
235) -> RuntimeError {
236    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
237    if let Some(identifier) = error.identifier {
238        builder = builder.with_identifier(identifier);
239    }
240    builder.build()
241}
242
243fn webwrite_error_with_source<E>(
244    error: &'static BuiltinErrorDescriptor,
245    message: impl Into<String>,
246    source: E,
247) -> RuntimeError
248where
249    E: std::error::Error + Send + Sync + 'static,
250{
251    let mut builder = build_runtime_error(message)
252        .with_builtin(BUILTIN_NAME)
253        .with_source(source);
254    if let Some(identifier) = error.identifier {
255        builder = builder.with_identifier(identifier);
256    }
257    builder.build()
258}
259
260fn remap_webwrite_flow<F>(
261    error: &'static BuiltinErrorDescriptor,
262    err: RuntimeError,
263    message: F,
264) -> RuntimeError
265where
266    F: FnOnce(&RuntimeError) -> String,
267{
268    let mut builder = build_runtime_error(message(&err))
269        .with_builtin(BUILTIN_NAME)
270        .with_source(err);
271    if let Some(identifier) = error.identifier {
272        builder = builder.with_identifier(identifier);
273    }
274    builder.build()
275}
276
277fn webwrite_flow_with_context(err: RuntimeError) -> RuntimeError {
278    remap_webwrite_flow(&WEBWRITE_ERROR_FLOW, err, |err| {
279        format!("webwrite: {}", err.message())
280    })
281}
282
283#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::http::webwrite")]
284pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
285    name: "webwrite",
286    shape: ShapeRequirements::Any,
287    constant_strategy: ConstantStrategy::InlineLiteral,
288    elementwise: None,
289    reduction: None,
290    emits_nan: false,
291    notes: "webwrite performs network I/O and terminates fusion graphs.",
292};
293
294#[runtime_builtin(
295    name = "webwrite",
296    category = "io/http",
297    summary = "Write data to web services via HTTP and return decoded responses.",
298    keywords = "webwrite,http post,rest client,json upload,form post",
299    accel = "sink",
300    type_resolver(crate::builtins::io::type_resolvers::webwrite_type),
301    descriptor(crate::builtins::io::http::webwrite::WEBWRITE_DESCRIPTOR),
302    builtin_path = "crate::builtins::io::http::webwrite"
303)]
304async fn webwrite_builtin(url: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
305    let gathered_url = gather_if_needed_async(&url)
306        .await
307        .map_err(webwrite_flow_with_context)?;
308    let url_text = expect_string_scalar(
309        &gathered_url,
310        "webwrite: URL must be a character vector or string scalar",
311    )?;
312    if url_text.trim().is_empty() {
313        return Err(webwrite_error_with(
314            &WEBWRITE_ERROR_INVALID_URL,
315            "webwrite: URL must not be empty",
316        ));
317    }
318    if rest.is_empty() {
319        return Err(webwrite_error_with(
320            &WEBWRITE_ERROR_MISSING_DATA,
321            WEBWRITE_ERROR_MISSING_DATA.message,
322        ));
323    }
324
325    let mut gathered = Vec::with_capacity(rest.len());
326    for value in rest {
327        gathered.push(
328            gather_if_needed_async(&value)
329                .await
330                .map_err(webwrite_flow_with_context)?,
331        );
332    }
333    let mut queue: VecDeque<Value> = VecDeque::from(gathered);
334    let data_value = queue.pop_front().ok_or_else(|| {
335        webwrite_error_with(
336            &WEBWRITE_ERROR_MISSING_DATA,
337            WEBWRITE_ERROR_MISSING_DATA.message,
338        )
339    })?;
340
341    let (options, query_params) = parse_arguments(queue)?;
342    let body = prepare_request_body(data_value, &options).await?;
343    execute_request(&url_text, options, &query_params, body)
344}
345
346fn parse_arguments(
347    mut queue: VecDeque<Value>,
348) -> BuiltinResult<(WebWriteOptions, Vec<(String, String)>)> {
349    let mut options = WebWriteOptions::default();
350    let mut query_params = Vec::new();
351
352    if matches!(queue.front(), Some(Value::Struct(_))) {
353        if let Some(Value::Struct(struct_value)) = queue.pop_front() {
354            process_struct_fields(&struct_value, &mut options, &mut query_params)?;
355        }
356    } else if matches!(queue.front(), Some(Value::Cell(_))) {
357        if let Some(Value::Cell(cell)) = queue.pop_front() {
358            append_query_from_cell(&cell, &mut query_params)?;
359        }
360    }
361
362    while let Some(name_value) = queue.pop_front() {
363        let name = expect_string_scalar(
364            &name_value,
365            "webwrite: parameter names must be character vectors or strings",
366        )?;
367        let value = queue.pop_front().ok_or_else(|| {
368            webwrite_error_with(
369                &WEBWRITE_ERROR_MISSING_OPTION_VALUE,
370                "webwrite: missing value for name-value argument",
371            )
372        })?;
373        process_name_value_pair(&name, &value, &mut options, &mut query_params)?;
374    }
375
376    Ok((options, query_params))
377}
378
379fn process_struct_fields(
380    struct_value: &StructValue,
381    options: &mut WebWriteOptions,
382    query_params: &mut Vec<(String, String)>,
383) -> BuiltinResult<()> {
384    for (key, value) in &struct_value.fields {
385        process_name_value_pair(key, value, options, query_params)?;
386    }
387    Ok(())
388}
389
390fn process_name_value_pair(
391    name: &str,
392    value: &Value,
393    options: &mut WebWriteOptions,
394    query_params: &mut Vec<(String, String)>,
395) -> BuiltinResult<()> {
396    let lower = name.to_ascii_lowercase();
397    match lower.as_str() {
398        "contenttype" => {
399            let ct = parse_content_type(value)?;
400            options.content_type = ct;
401            Ok(())
402        }
403        "mediatype" => {
404            let media = expect_string_scalar(
405                value,
406                "webwrite: MediaType must be a character vector or string scalar",
407            )?;
408            let trimmed = media.trim();
409            if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
410                options.media_type = None;
411                options.request_format = RequestFormat::Auto;
412                options.request_format_explicit = false;
413            } else {
414                options.media_type = Some(media.clone());
415                options.request_format = infer_request_format(&media);
416                options.request_format_explicit = true;
417            }
418            Ok(())
419        }
420        "timeout" => {
421            options.timeout = parse_timeout(value)?;
422            Ok(())
423        }
424        "headerfields" => {
425            let headers = parse_header_fields(value)?;
426            options.headers.extend(headers);
427            Ok(())
428        }
429        "useragent" => {
430            options.user_agent = Some(expect_string_scalar(
431                value,
432                "webwrite: UserAgent must be a character vector or string scalar",
433            )?);
434            Ok(())
435        }
436        "username" => {
437            options.username = Some(expect_string_scalar(
438                value,
439                "webwrite: Username must be a character vector or string scalar",
440            )?);
441            Ok(())
442        }
443        "password" => {
444            options.password = Some(expect_string_scalar(
445                value,
446                "webwrite: Password must be a character vector or string scalar",
447            )?);
448            Ok(())
449        }
450        "requestmethod" => {
451            options.method = parse_request_method(value)?;
452            Ok(())
453        }
454        "queryparameters" => append_query_from_value(value, query_params),
455        _ => {
456            let param_value = value_to_query_string(value, name)?;
457            query_params.push((name.to_string(), param_value));
458            Ok(())
459        }
460    }
461}
462
463fn execute_request(
464    url_text: &str,
465    options: WebWriteOptions,
466    query_params: &[(String, String)],
467    body: PreparedBody,
468) -> BuiltinResult<Value> {
469    let username_present = options
470        .username
471        .as_ref()
472        .map(|s| !s.is_empty())
473        .unwrap_or(false);
474    let password_present = options
475        .password
476        .as_ref()
477        .map(|s| !s.is_empty())
478        .unwrap_or(false);
479    if password_present && !username_present {
480        return Err(webwrite_error_with(
481            &WEBWRITE_ERROR_INVALID_CREDENTIALS,
482            "webwrite: Password requires a Username option",
483        ));
484    }
485
486    let mut url = Url::parse(url_text).map_err(|err| {
487        webwrite_error_with_source(
488            &WEBWRITE_ERROR_INVALID_URL,
489            format!("webwrite: invalid URL '{url_text}': {err}"),
490            err,
491        )
492    })?;
493    if !query_params.is_empty() {
494        {
495            let mut pairs = url.query_pairs_mut();
496            for (name, value) in query_params {
497                pairs.append_pair(name, value);
498            }
499        }
500    }
501    let user_agent = options
502        .user_agent
503        .as_deref()
504        .filter(|ua| !ua.trim().is_empty())
505        .unwrap_or(DEFAULT_USER_AGENT)
506        .to_string();
507
508    let mut headers = options.headers.clone();
509    let has_auth_header = headers
510        .iter()
511        .any(|(name, _)| name.eq_ignore_ascii_case("authorization"));
512    if !has_auth_header {
513        if let Some(username) = options.username.as_ref().filter(|s| !s.is_empty()) {
514            let password = options.password.clone().unwrap_or_default();
515            let token = BASE64_ENGINE.encode(format!("{username}:{password}"));
516            headers.push(("Authorization".to_string(), format!("Basic {token}")));
517        }
518    }
519
520    let has_ct_header = headers
521        .iter()
522        .any(|(name, _)| name.eq_ignore_ascii_case("content-type"));
523    if !has_ct_header {
524        if let Some(ct) = &body.content_type {
525            headers.push(("Content-Type".to_string(), ct.clone()));
526        }
527    }
528
529    let request = HttpRequest {
530        url,
531        method: options.method,
532        headers,
533        body: Some(body.bytes),
534        timeout: options.timeout,
535        user_agent,
536    };
537
538    let response = transport::send_request(&request).map_err(|err| {
539        webwrite_error_with_source(
540            &WEBWRITE_ERROR_TRANSPORT,
541            err.message_with_prefix("webwrite"),
542            err,
543        )
544    })?;
545
546    let header_content_type =
547        header_value(&response.headers, HEADER_CONTENT_TYPE).map(|value| value.to_string());
548    let resolved = options.resolve_content_type(header_content_type.as_deref());
549
550    match resolved {
551        ResolvedContentType::Json => {
552            let body_text = decode_body_as_text(&response.body, header_content_type.as_deref());
553            let value = decode_json_text(&body_text).map_err(map_json_error)?;
554            Ok(value)
555        }
556        ResolvedContentType::Text => {
557            let body_text = decode_body_as_text(&response.body, header_content_type.as_deref());
558            Ok(Value::CharArray(CharArray::new_row(&body_text)))
559        }
560        ResolvedContentType::Binary => {
561            let data: Vec<f64> = response.body.iter().map(|b| f64::from(*b)).collect();
562            let cols = data.len();
563            let tensor = Tensor::new(data, vec![1, cols]).map_err(|err| {
564                webwrite_error_with(&WEBWRITE_ERROR_OUTPUT, format!("webwrite: {err}"))
565            })?;
566            Ok(Value::Tensor(tensor))
567        }
568    }
569}
570
571async fn prepare_request_body(
572    data: Value,
573    options: &WebWriteOptions,
574) -> BuiltinResult<PreparedBody> {
575    let format = match options.request_format {
576        RequestFormat::Auto => guess_request_format(&data),
577        set => set,
578    };
579    let content_type = options
580        .media_type
581        .clone()
582        .or_else(|| default_content_type_for(format));
583    let bytes = match format {
584        RequestFormat::Form => encode_form_payload(&data)?,
585        RequestFormat::Json => encode_json_payload(&data).await?,
586        RequestFormat::Text => encode_text_payload(&data)?,
587        RequestFormat::Binary => encode_binary_payload(&data)?,
588        RequestFormat::Auto => encode_json_payload(&data).await?,
589    };
590    Ok(PreparedBody {
591        bytes,
592        content_type,
593    })
594}
595
596fn encode_form_payload(value: &Value) -> BuiltinResult<Vec<u8>> {
597    let mut pairs = Vec::new();
598    match value {
599        Value::Struct(struct_value) => {
600            for (key, val) in &struct_value.fields {
601                let text = value_to_query_string(val, key)?;
602                pairs.push((key.clone(), text));
603            }
604        }
605        Value::Cell(cell) => {
606            append_query_from_cell(cell, &mut pairs)?;
607        }
608        Value::CharArray(_)
609        | Value::String(_)
610        | Value::Num(_)
611        | Value::Int(_)
612        | Value::Tensor(_) => {
613            // Allow scalar text/numeric by mapping to a default "data" key.
614            let text = scalar_to_string(value)?;
615            pairs.push(("data".to_string(), text));
616        }
617        _ => {
618            return Err(webwrite_error(
619                "webwrite: form payloads must be structs, two-column cell arrays, or scalars",
620            ))
621        }
622    }
623
624    let encoded = encode_form_pairs(&pairs);
625    Ok(encoded.into_bytes())
626}
627
628fn encode_form_pairs(pairs: &[(String, String)]) -> String {
629    let mut result = String::new();
630    for (idx, (name, value)) in pairs.iter().enumerate() {
631        if idx > 0 {
632            result.push('&');
633        }
634        result.push_str(&url_encode_component(name));
635        result.push('=');
636        result.push_str(&url_encode_component(value));
637    }
638    result
639}
640
641fn url_encode_component(input: &str) -> String {
642    let mut out = String::new();
643    for byte in input.bytes() {
644        match byte {
645            b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'*' => {
646                out.push(byte as char);
647            }
648            b' ' => out.push('+'),
649            _ => {
650                out.push('%');
651                out.push(hex_digit(byte >> 4));
652                out.push(hex_digit(byte & 0xF));
653            }
654        }
655    }
656    out
657}
658
659fn hex_digit(nibble: u8) -> char {
660    match nibble {
661        0..=9 => (b'0' + nibble) as char,
662        10..=15 => (b'A' + (nibble - 10)) as char,
663        _ => unreachable!(),
664    }
665}
666
667async fn encode_json_payload(value: &Value) -> BuiltinResult<Vec<u8>> {
668    let encoded = call_builtin_async("jsonencode", std::slice::from_ref(value))
669        .await
670        .map_err(|flow| {
671            remap_webwrite_flow(&WEBWRITE_ERROR_FLOW, flow, |err| {
672                format!("webwrite: {}", err.message())
673            })
674        })?;
675    let text = expect_string_scalar(
676        &encoded,
677        "webwrite: jsonencode returned unexpected value; expected text scalar",
678    )?;
679    Ok(text.into_bytes())
680}
681
682fn encode_text_payload(value: &Value) -> BuiltinResult<Vec<u8>> {
683    let text = scalar_to_string(value)?;
684    Ok(text.into_bytes())
685}
686
687fn encode_binary_payload(value: &Value) -> BuiltinResult<Vec<u8>> {
688    match value {
689        Value::Tensor(tensor) => tensor_f64_to_bytes(tensor),
690        Value::Num(n) => Ok(vec![float_to_byte(*n)?]),
691        Value::Int(i) => Ok(vec![int_to_byte(i.to_i64())?]),
692        Value::Bool(b) => Ok(vec![if *b { 1 } else { 0 }]),
693        Value::LogicalArray(array) => Ok(array.data.clone()),
694        Value::CharArray(ca) => {
695            let mut bytes = Vec::with_capacity(ca.data.len());
696            for ch in &ca.data {
697                let code = *ch as u32;
698                if code > 0xFF {
699                    return Err(webwrite_error(
700                        "webwrite: character codes exceed 255 for binary payload",
701                    ));
702                }
703                bytes.push(code as u8);
704            }
705            Ok(bytes)
706        }
707        Value::String(s) => Ok(s.as_bytes().to_vec()),
708        Value::StringArray(sa) => {
709            if sa.data.len() == 1 {
710                Ok(sa.data[0].as_bytes().to_vec())
711            } else {
712                Err(webwrite_error(
713                    "webwrite: binary payload string arrays must be scalar",
714                ))
715            }
716        }
717        _ => Err(webwrite_error(
718            "webwrite: unsupported value for binary payload",
719        )),
720    }
721}
722
723fn tensor_f64_to_bytes(tensor: &Tensor) -> BuiltinResult<Vec<u8>> {
724    let mut bytes = Vec::with_capacity(tensor.data.len());
725    for value in &tensor.data {
726        bytes.push(float_to_byte(*value)?);
727    }
728    Ok(bytes)
729}
730
731fn float_to_byte(value: f64) -> BuiltinResult<u8> {
732    if !value.is_finite() {
733        return Err(webwrite_error(
734            "webwrite: binary payload values must be finite",
735        ));
736    }
737    let rounded = value.round();
738    if (value - rounded).abs() > 1e-9 {
739        return Err(webwrite_error(
740            "webwrite: binary payload values must be integers in 0..255",
741        ));
742    }
743    let int_val = rounded as i64;
744    int_to_byte(int_val)
745}
746
747fn int_to_byte(value: i64) -> BuiltinResult<u8> {
748    if !(0..=255).contains(&value) {
749        return Err(webwrite_error(
750            "webwrite: binary payload values must be in the range 0..255",
751        ));
752    }
753
754    Ok(value as u8)
755}
756
757fn append_query_from_value(
758    value: &Value,
759    query_params: &mut Vec<(String, String)>,
760) -> BuiltinResult<()> {
761    match value {
762        Value::Struct(struct_value) => {
763            for (key, val) in &struct_value.fields {
764                let text = value_to_query_string(val, key)?;
765                query_params.push((key.clone(), text));
766            }
767            Ok(())
768        }
769        Value::Cell(cell) => append_query_from_cell(cell, query_params),
770        _ => Err(webwrite_error(
771            "webwrite: QueryParameters must be a struct or cell array",
772        )),
773    }
774}
775
776fn append_query_from_cell(
777    cell: &CellArray,
778    query_params: &mut Vec<(String, String)>,
779) -> BuiltinResult<()> {
780    if cell.cols != 2 {
781        return Err(webwrite_error(
782            "webwrite: cell array of query parameters must have two columns",
783        ));
784    }
785    for row in 0..cell.rows {
786        let name_value = cell
787            .get(row, 0)
788            .map_err(|err| webwrite_error(format!("webwrite: {err}")))?;
789        let value_value = cell
790            .get(row, 1)
791            .map_err(|err| webwrite_error(format!("webwrite: {err}")))?;
792        let name = expect_string_scalar(
793            &name_value,
794            "webwrite: query parameter names must be text scalars",
795        )?;
796        let text = value_to_query_string(&value_value, &name)?;
797        query_params.push((name, text));
798    }
799    Ok(())
800}
801
802fn parse_content_type(value: &Value) -> BuiltinResult<ContentTypeHint> {
803    let text = expect_string_scalar(
804        value,
805        "webwrite: ContentType must be a character vector or string scalar",
806    )?;
807    let lower = text.trim().to_ascii_lowercase();
808    match lower.as_str() {
809        "auto" => Ok(ContentTypeHint::Auto),
810        "json" => Ok(ContentTypeHint::Json),
811        "text" => Ok(ContentTypeHint::Text),
812        "binary" => Ok(ContentTypeHint::Binary),
813        _ => Err(webwrite_error(
814            "webwrite: ContentType must be 'auto', 'json', 'text', or 'binary'",
815        )),
816    }
817}
818
819fn parse_timeout(value: &Value) -> BuiltinResult<Duration> {
820    let seconds = numeric_scalar(
821        value,
822        "webwrite: Timeout must be a finite, non-negative scalar numeric value",
823    )?;
824    if !seconds.is_finite() || seconds < 0.0 {
825        return Err(webwrite_error(
826            "webwrite: Timeout must be a finite, non-negative scalar numeric value",
827        ));
828    }
829    Ok(Duration::from_secs_f64(seconds))
830}
831
832fn parse_request_method(value: &Value) -> BuiltinResult<HttpMethod> {
833    let text = expect_string_scalar(
834        value,
835        "webwrite: RequestMethod must be a character vector or string scalar",
836    )?;
837    match text.trim().to_ascii_lowercase().as_str() {
838        "auto" => Ok(HttpMethod::Post),
839        "post" => Ok(HttpMethod::Post),
840        "put" => Ok(HttpMethod::Put),
841        "patch" => Ok(HttpMethod::Patch),
842        "delete" => Ok(HttpMethod::Delete),
843        other => Err(webwrite_error(format!(
844            "webwrite: unsupported RequestMethod '{}'; expected auto, post, put, patch, or delete",
845            other
846        ))),
847    }
848}
849
850fn parse_header_fields(value: &Value) -> BuiltinResult<Vec<(String, String)>> {
851    match value {
852        Value::Struct(struct_value) => {
853            let mut headers = Vec::with_capacity(struct_value.fields.len());
854            for (key, val) in &struct_value.fields {
855                let header_value = expect_string_scalar(
856                    val,
857                    "webwrite: header values must be character vectors or string scalars",
858                )?;
859                headers.push((key.clone(), header_value));
860            }
861            Ok(headers)
862        }
863        Value::Cell(cell) => {
864            if cell.cols != 2 {
865                return Err(webwrite_error(
866                    "webwrite: HeaderFields cell array must have exactly two columns",
867                ));
868            }
869            let mut headers = Vec::with_capacity(cell.rows);
870            for row in 0..cell.rows {
871                let name = cell
872                    .get(row, 0)
873                    .map_err(|err| webwrite_error(format!("webwrite: {err}")))?;
874                let value = cell
875                    .get(row, 1)
876                    .map_err(|err| webwrite_error(format!("webwrite: {err}")))?;
877                let header_name = expect_string_scalar(
878                    &name,
879                    "webwrite: header names must be character vectors or string scalars",
880                )?;
881                if header_name.trim().is_empty() {
882                    return Err(webwrite_error("webwrite: header names must not be empty"));
883                }
884                let header_value = expect_string_scalar(
885                    &value,
886                    "webwrite: header values must be character vectors or string scalars",
887                )?;
888                headers.push((header_name, header_value));
889            }
890            Ok(headers)
891        }
892        _ => Err(webwrite_error(
893            "webwrite: HeaderFields must be a struct or two-column cell array",
894        )),
895    }
896}
897
898fn map_json_error(err: RuntimeError) -> RuntimeError {
899    let message = if let Some(rest) = err.message().strip_prefix("jsondecode: ") {
900        format!("webwrite: failed to parse JSON response ({rest})")
901    } else {
902        format!(
903            "webwrite: failed to parse JSON response ({})",
904            err.message()
905        )
906    };
907    webwrite_error_with_source(&WEBWRITE_ERROR_RESPONSE_JSON, message, err)
908}
909
910fn numeric_scalar(value: &Value, context: &str) -> BuiltinResult<f64> {
911    match value {
912        Value::Num(n) => Ok(*n),
913        Value::Int(i) => Ok(i.to_f64()),
914        Value::Tensor(tensor) => {
915            if tensor.data.len() == 1 {
916                Ok(tensor.data[0])
917            } else {
918                Err(webwrite_error(context))
919            }
920        }
921        _ => Err(webwrite_error(context)),
922    }
923}
924
925fn scalar_to_string(value: &Value) -> BuiltinResult<String> {
926    match value {
927        Value::String(s) => Ok(s.clone()),
928        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
929        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
930        Value::Num(n) => Ok(format!("{}", n)),
931        Value::Int(i) => Ok(i.to_i64().to_string()),
932        Value::Bool(b) => Ok(if *b { "true".into() } else { "false".into() }),
933        Value::Tensor(tensor) => {
934            if tensor.data.len() == 1 {
935                Ok(format!("{}", tensor.data[0]))
936            } else {
937                Err(webwrite_error(
938                    "webwrite: expected scalar value for text payload",
939                ))
940            }
941        }
942        Value::LogicalArray(array) => {
943            if array.len() == 1 {
944                Ok(if array.data[0] != 0 {
945                    "true".into()
946                } else {
947                    "false".into()
948                })
949            } else {
950                Err(webwrite_error(
951                    "webwrite: expected scalar value for text payload",
952                ))
953            }
954        }
955        _ => Err(webwrite_error(
956            "webwrite: unsupported value type for text payload",
957        )),
958    }
959}
960
961fn expect_string_scalar(value: &Value, context: &str) -> BuiltinResult<String> {
962    match value {
963        Value::String(s) => Ok(s.clone()),
964        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
965        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
966        _ => Err(webwrite_error(context)),
967    }
968}
969
970fn value_to_query_string(value: &Value, name: &str) -> BuiltinResult<String> {
971    match value {
972        Value::String(s) => Ok(s.clone()),
973        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
974        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
975        Value::Num(n) => Ok(format!("{}", n)),
976        Value::Int(i) => Ok(i.to_i64().to_string()),
977        Value::Bool(b) => Ok(if *b { "true".into() } else { "false".into() }),
978        Value::Tensor(tensor) => {
979            if tensor.data.len() == 1 {
980                Ok(format!("{}", tensor.data[0]))
981            } else {
982                Err(webwrite_error(format!(
983                    "webwrite: query parameter '{}' must be scalar",
984                    name
985                )))
986            }
987        }
988        Value::LogicalArray(array) => {
989            if array.len() == 1 {
990                Ok(if array.data[0] != 0 {
991                    "true".into()
992                } else {
993                    "false".into()
994                })
995            } else {
996                Err(webwrite_error(format!(
997                    "webwrite: query parameter '{}' must be scalar",
998                    name
999                )))
1000            }
1001        }
1002        _ => Err(webwrite_error(format!(
1003            "webwrite: unsupported value type for query parameter '{}'",
1004            name
1005        ))),
1006    }
1007}
1008
1009fn guess_request_format(value: &Value) -> RequestFormat {
1010    match value {
1011        Value::Struct(_) => RequestFormat::Form,
1012        Value::Cell(cell) if cell.cols == 2 => RequestFormat::Form,
1013        Value::CharArray(ca) if ca.rows == 1 => RequestFormat::Text,
1014        Value::String(_) => RequestFormat::Text,
1015        Value::StringArray(sa) => {
1016            if sa.data.len() == 1 {
1017                RequestFormat::Text
1018            } else {
1019                RequestFormat::Json
1020            }
1021        }
1022        Value::Tensor(_) | Value::LogicalArray(_) => RequestFormat::Json,
1023        Value::Num(_) | Value::Int(_) | Value::Bool(_) => RequestFormat::Json,
1024        _ => RequestFormat::Json,
1025    }
1026}
1027
1028fn infer_request_format(media_type: &str) -> RequestFormat {
1029    let lower = media_type.trim().to_ascii_lowercase();
1030    if lower.contains("json") {
1031        RequestFormat::Json
1032    } else if lower.starts_with("text/") || lower.contains("xml") {
1033        RequestFormat::Text
1034    } else if lower == "application/x-www-form-urlencoded" {
1035        RequestFormat::Form
1036    } else {
1037        RequestFormat::Binary
1038    }
1039}
1040
1041fn default_content_type_for(format: RequestFormat) -> Option<String> {
1042    match format {
1043        RequestFormat::Form => Some("application/x-www-form-urlencoded".to_string()),
1044        RequestFormat::Json => Some("application/json".to_string()),
1045        RequestFormat::Text => Some("text/plain; charset=utf-8".to_string()),
1046        RequestFormat::Binary => Some("application/octet-stream".to_string()),
1047        RequestFormat::Auto => None,
1048    }
1049}
1050
1051#[derive(Clone, Debug)]
1052struct PreparedBody {
1053    bytes: Vec<u8>,
1054    content_type: Option<String>,
1055}
1056
1057#[derive(Clone, Copy, Debug)]
1058enum ContentTypeHint {
1059    Auto,
1060    Text,
1061    Json,
1062    Binary,
1063}
1064
1065#[derive(Clone, Copy, Debug)]
1066enum ResolvedContentType {
1067    Text,
1068    Json,
1069    Binary,
1070}
1071
1072#[derive(Clone, Copy, Debug)]
1073enum RequestFormat {
1074    Auto,
1075    Form,
1076    Json,
1077    Text,
1078    Binary,
1079}
1080
1081#[derive(Clone, Debug)]
1082struct WebWriteOptions {
1083    content_type: ContentTypeHint,
1084    timeout: Duration,
1085    headers: Vec<(String, String)>,
1086    user_agent: Option<String>,
1087    username: Option<String>,
1088    password: Option<String>,
1089    method: HttpMethod,
1090    request_format: RequestFormat,
1091    request_format_explicit: bool,
1092    media_type: Option<String>,
1093}
1094
1095impl Default for WebWriteOptions {
1096    fn default() -> Self {
1097        Self {
1098            content_type: ContentTypeHint::Auto,
1099            timeout: Duration::from_secs_f64(DEFAULT_TIMEOUT_SECONDS),
1100            headers: Vec::new(),
1101            user_agent: None,
1102            username: None,
1103            password: None,
1104            method: HttpMethod::Post,
1105            request_format: RequestFormat::Auto,
1106            request_format_explicit: false,
1107            media_type: None,
1108        }
1109    }
1110}
1111
1112impl WebWriteOptions {
1113    fn resolve_content_type(&self, header: Option<&str>) -> ResolvedContentType {
1114        match self.content_type {
1115            ContentTypeHint::Json => ResolvedContentType::Json,
1116            ContentTypeHint::Text => ResolvedContentType::Text,
1117            ContentTypeHint::Binary => ResolvedContentType::Binary,
1118            ContentTypeHint::Auto => infer_response_content_type(header),
1119        }
1120    }
1121}
1122
1123fn infer_response_content_type(header: Option<&str>) -> ResolvedContentType {
1124    if let Some(raw) = header {
1125        let mime = raw
1126            .split(';')
1127            .next()
1128            .map(|part| part.trim().to_ascii_lowercase())
1129            .unwrap_or_default();
1130        if mime == "application/json" || mime == "text/json" || mime.ends_with("+json") {
1131            ResolvedContentType::Json
1132        } else if mime.starts_with("text/")
1133            || mime == "application/xml"
1134            || mime.ends_with("+xml")
1135            || mime == "application/xhtml+xml"
1136            || mime == "application/javascript"
1137            || mime == "application/x-www-form-urlencoded"
1138        {
1139            ResolvedContentType::Text
1140        } else {
1141            ResolvedContentType::Binary
1142        }
1143    } else {
1144        ResolvedContentType::Text
1145    }
1146}
1147
1148#[cfg(test)]
1149pub(crate) mod tests {
1150    use super::*;
1151    use std::io::{Read, Write};
1152    use std::net::{TcpListener, TcpStream};
1153    use std::sync::mpsc;
1154    use std::thread;
1155
1156    fn spawn_server<F>(handler: F) -> String
1157    where
1158        F: FnOnce(TcpStream) + Send + 'static,
1159    {
1160        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
1161        let addr = listener.local_addr().unwrap();
1162        thread::spawn(move || {
1163            if let Ok((stream, _)) = listener.accept() {
1164                handler(stream);
1165            }
1166        });
1167        format!("http://{}", addr)
1168    }
1169
1170    fn read_request(stream: &mut TcpStream) -> (String, Vec<u8>) {
1171        let mut buffer = Vec::new();
1172        let mut tmp = [0u8; 512];
1173        let mut header_end = None;
1174        loop {
1175            match stream.read(&mut tmp) {
1176                Ok(0) => break,
1177                Ok(n) => {
1178                    buffer.extend_from_slice(&tmp[..n]);
1179                    if let Some(idx) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
1180                        header_end = Some(idx + 4);
1181                        break;
1182                    }
1183                }
1184                Err(_) => break,
1185            }
1186        }
1187        let header_end = header_end.unwrap_or(buffer.len());
1188        let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
1189        let content_length = headers
1190            .lines()
1191            .find_map(|line| {
1192                let mut parts = line.splitn(2, ':');
1193                let name = parts.next()?.trim();
1194                let value = parts.next()?.trim();
1195                if name.eq_ignore_ascii_case("content-length") {
1196                    value.parse::<usize>().ok()
1197                } else {
1198                    None
1199                }
1200            })
1201            .unwrap_or(0);
1202        let mut body = buffer[header_end..].to_vec();
1203        while body.len() < content_length {
1204            match stream.read(&mut tmp) {
1205                Ok(0) => break,
1206                Ok(n) => body.extend_from_slice(&tmp[..n]),
1207                Err(_) => break,
1208            }
1209        }
1210        (headers, body)
1211    }
1212
1213    fn respond_with(mut stream: TcpStream, content_type: &str, body: &[u8]) {
1214        let response = format!(
1215            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n",
1216            body.len(),
1217            content_type
1218        );
1219        let _ = stream.write_all(response.as_bytes());
1220        let _ = stream.write_all(body);
1221    }
1222
1223    fn run_webwrite(url: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
1224        futures::executor::block_on(webwrite_builtin(url, rest))
1225    }
1226
1227    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1228    #[test]
1229    fn webwrite_descriptor_signatures_cover_core_forms() {
1230        let labels: Vec<&str> = WEBWRITE_DESCRIPTOR
1231            .signatures
1232            .iter()
1233            .map(|sig| sig.label)
1234            .collect();
1235        assert!(labels.contains(&"response = webwrite(url, data)"));
1236        assert!(labels.contains(&"response = webwrite(url, data, optionsStruct)"));
1237        assert!(labels.contains(&"response = webwrite(url, data, name, value, ...)"));
1238    }
1239
1240    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1241    #[test]
1242    fn webwrite_posts_form_data_by_default() {
1243        let payload = {
1244            let mut st = StructValue::new();
1245            st.fields.insert("name".to_string(), Value::from("Ada"));
1246            st.fields.insert("score".to_string(), Value::Num(42.0));
1247            st
1248        };
1249        let opts = {
1250            let mut st = StructValue::new();
1251            st.fields
1252                .insert("ContentType".to_string(), Value::from("json"));
1253            st
1254        };
1255
1256        let (tx, rx) = mpsc::channel();
1257        let url = spawn_server(move |mut stream| {
1258            let (headers, body) = read_request(&mut stream);
1259            tx.send((headers, body)).unwrap();
1260            respond_with(
1261                stream,
1262                "application/json",
1263                br#"{"status":"ok","received":true}"#,
1264            );
1265        });
1266
1267        let result = run_webwrite(
1268            Value::from(url),
1269            vec![Value::Struct(payload), Value::Struct(opts)],
1270        )
1271        .expect("webwrite");
1272
1273        let (headers, body) = rx.recv().expect("request captured");
1274        assert!(headers.starts_with("POST "));
1275        let headers_lower = headers.to_ascii_lowercase();
1276        assert!(headers_lower.contains("content-type: application/x-www-form-urlencoded"));
1277        let body_text = String::from_utf8(body).expect("utf8 body");
1278        assert!(body_text.contains("name=Ada"));
1279        assert!(body_text.contains("score=42"));
1280
1281        match result {
1282            Value::Struct(reply) => {
1283                assert!(matches!(
1284                    reply.fields.get("received"),
1285                    Some(Value::Bool(true))
1286                ));
1287            }
1288            other => panic!("expected struct response, got {other:?}"),
1289        }
1290    }
1291
1292    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1293    #[test]
1294    fn webwrite_sends_json_when_media_type_json() {
1295        let payload = {
1296            let mut st = StructValue::new();
1297            st.fields.insert("title".to_string(), Value::from("RunMat"));
1298            st.fields.insert("stars".to_string(), Value::Num(5.0));
1299            st
1300        };
1301        let opts = {
1302            let mut st = StructValue::new();
1303            st.fields.insert(
1304                "MediaType".to_string(),
1305                Value::from("application/json; charset=utf-8"),
1306            );
1307            st.fields
1308                .insert("ContentType".to_string(), Value::from("json"));
1309            st
1310        };
1311
1312        let (tx, rx) = mpsc::channel();
1313        let url = spawn_server(move |mut stream| {
1314            let (headers, body) = read_request(&mut stream);
1315            tx.send((headers, body)).unwrap();
1316            respond_with(stream, "application/json", br#"{"ok":true}"#);
1317        });
1318
1319        let result = run_webwrite(
1320            Value::from(url),
1321            vec![Value::Struct(payload), Value::Struct(opts)],
1322        )
1323        .expect("webwrite");
1324
1325        let (headers, body) = rx.recv().expect("request");
1326        let headers_lower = headers.to_ascii_lowercase();
1327        assert!(headers_lower.contains("content-type: application/json"));
1328        let body_text = String::from_utf8(body).expect("utf8 body");
1329        assert!(body_text.contains("\"title\":\"RunMat\""));
1330        assert!(body_text.contains("\"stars\":5"));
1331
1332        match result {
1333            Value::Struct(reply) => {
1334                assert!(matches!(reply.fields.get("ok"), Some(Value::Bool(true))));
1335            }
1336            other => panic!("expected struct response, got {other:?}"),
1337        }
1338    }
1339
1340    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1341    #[test]
1342    fn webwrite_applies_basic_auth_and_custom_headers() {
1343        let payload = Value::from("");
1344        let mut header_struct = StructValue::new();
1345        header_struct
1346            .fields
1347            .insert("X-Test".to_string(), Value::from("yes"));
1348        header_struct
1349            .fields
1350            .insert("Accept".to_string(), Value::from("text/plain"));
1351        let mut opts_struct = StructValue::new();
1352        opts_struct
1353            .fields
1354            .insert("Username".to_string(), Value::from("ada"));
1355        opts_struct
1356            .fields
1357            .insert("Password".to_string(), Value::from("secret"));
1358        opts_struct
1359            .fields
1360            .insert("HeaderFields".to_string(), Value::Struct(header_struct));
1361        opts_struct
1362            .fields
1363            .insert("ContentType".to_string(), Value::from("text"));
1364        opts_struct
1365            .fields
1366            .insert("MediaType".to_string(), Value::from("text/plain"));
1367
1368        let (tx, rx) = mpsc::channel();
1369        let url = spawn_server(move |mut stream| {
1370            let (headers, _) = read_request(&mut stream);
1371            tx.send(headers).unwrap();
1372            respond_with(stream, "text/plain", b"OK");
1373        });
1374
1375        let result = run_webwrite(Value::from(url), vec![payload, Value::Struct(opts_struct)])
1376            .expect("webwrite");
1377
1378        let headers = rx.recv().expect("headers");
1379        let headers_lower = headers.to_ascii_lowercase();
1380        assert!(headers_lower.contains("authorization: basic"));
1381        assert!(headers_lower.contains("x-test: yes"));
1382        assert!(headers_lower.contains("accept: text/plain"));
1383
1384        match result {
1385            Value::CharArray(ca) => {
1386                let text: String = ca.data.iter().collect();
1387                assert_eq!(text, "OK");
1388            }
1389            other => panic!("expected char array, got {other:?}"),
1390        }
1391    }
1392
1393    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1394    #[test]
1395    fn webwrite_supports_query_parameters() {
1396        let payload = Value::Struct(StructValue::new());
1397        let mut qp_struct = StructValue::new();
1398        qp_struct.fields.insert("page".to_string(), Value::Num(2.0));
1399        qp_struct
1400            .fields
1401            .insert("verbose".to_string(), Value::Bool(true));
1402        let mut opts_struct = StructValue::new();
1403        opts_struct
1404            .fields
1405            .insert("QueryParameters".to_string(), Value::Struct(qp_struct));
1406
1407        let (tx, rx) = mpsc::channel();
1408        let url = spawn_server(move |mut stream| {
1409            let (headers, _) = read_request(&mut stream);
1410            tx.send(headers).unwrap();
1411            respond_with(stream, "application/json", br#"{"ok":true}"#);
1412        });
1413
1414        let _ = run_webwrite(
1415            Value::from(url.clone()),
1416            vec![payload, Value::Struct(opts_struct)],
1417        )
1418        .expect("webwrite");
1419
1420        let headers = rx.recv().expect("headers");
1421        let first_line = headers.lines().next().unwrap_or("");
1422        assert!(first_line.starts_with("POST "));
1423        assert!(first_line.contains("page=2"));
1424        assert!(first_line.contains("verbose=true"));
1425    }
1426
1427    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1428    #[test]
1429    fn webwrite_binary_payload_respected() {
1430        let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 255.0], vec![4, 1]).unwrap();
1431        let payload = Value::Tensor(tensor);
1432        let mut opts_struct = StructValue::new();
1433        opts_struct
1434            .fields
1435            .insert("ContentType".to_string(), Value::from("binary"));
1436        opts_struct.fields.insert(
1437            "MediaType".to_string(),
1438            Value::from("application/octet-stream"),
1439        );
1440
1441        let (tx, rx) = mpsc::channel();
1442        let url = spawn_server(move |mut stream| {
1443            let (headers, body) = read_request(&mut stream);
1444            tx.send((headers, body)).unwrap();
1445            respond_with(stream, "text/plain", b"OK");
1446        });
1447
1448        let _ = run_webwrite(Value::from(url), vec![payload, Value::Struct(opts_struct)])
1449            .expect("webwrite");
1450
1451        let (headers, body) = rx.recv().expect("request");
1452        let headers_lower = headers.to_ascii_lowercase();
1453        assert!(headers_lower.contains("content-type: application/octet-stream"));
1454        assert_eq!(body, vec![1, 2, 3, 255]);
1455    }
1456}