1use 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 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}