Skip to main content

runmat_runtime/builtins/io/http/
webwrite.rs

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