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