Skip to content

Commit b9dce3d

Browse files
committed
feat: added schema conversion based on http_schema
1 parent 8d0c365 commit b9dce3d

1 file changed

Lines changed: 193 additions & 136 deletions

File tree

  • crates/taurus-core/src/runtime/functions

crates/taurus-core/src/runtime/functions/http.rs

Lines changed: 193 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::types::errors::runtime_error::RuntimeError;
1010
use crate::types::signal::Signal;
1111
use crate::value::number_to_string;
1212
use base64::Engine;
13+
use lupus::{DecodeContext, EncodeContext, Engine as ConversionEngine, Format};
1314
use serde_json::Value as JsonValue;
1415
use std::collections::HashMap;
1516
use std::io::Read;
@@ -151,7 +152,7 @@ fn send_request(
151152
http_auth: Value,
152153
http_auth_value: Value,
153154
http_auth_place: Value,
154-
_http_schema: Value,
155+
http_schema: String,
155156
payload: Value,
156157
headers: Value,
157158
);
@@ -179,22 +180,12 @@ fn send_request(
179180
return fail("InvalidArgumentRuntimeError", message);
180181
}
181182

182-
let request_content_type = content_type_header_value(&headers);
183-
let (request_body, default_content_type) =
184-
match encode_request_payload(&payload, request_content_type.as_deref()) {
185-
Ok(result) => result,
186-
Err(message) => return fail("InvalidArgumentRuntimeError", message),
187-
};
183+
let request_body = match encode_request_payload(&payload, &http_schema) {
184+
Ok(result) => result,
185+
Err(message) => return fail("InvalidArgumentRuntimeError", message),
186+
};
188187

189-
if let Some(default_content_type) = default_content_type
190-
&& request_content_type.is_none()
191-
{
192-
insert_header(
193-
&mut headers,
194-
"content-type",
195-
default_content_type.to_string(),
196-
);
197-
}
188+
insert_header(&mut headers, "content-type", http_schema);
198189

199190
let http_method = match http::Method::from_bytes(http_method.as_bytes()) {
200191
Ok(value) => value,
@@ -419,16 +410,6 @@ fn value_to_string(value: &Value) -> Result<String, String> {
419410
}
420411
}
421412

422-
fn content_type_header_value(headers: &HashMap<String, String>) -> Option<String> {
423-
headers.iter().find_map(|(name, value)| {
424-
if name.eq_ignore_ascii_case("content-type") {
425-
Some(value.clone())
426-
} else {
427-
None
428-
}
429-
})
430-
}
431-
432413
fn normalize_content_type(content_type: &str) -> String {
433414
content_type
434415
.split(';')
@@ -438,54 +419,52 @@ fn normalize_content_type(content_type: &str) -> String {
438419
.to_ascii_lowercase()
439420
}
440421

441-
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
442-
enum RequestBodyEncoding {
443-
Json,
444-
Text,
445-
}
446-
447-
fn resolve_request_body_encoding(
448-
payload: &Value,
449-
request_content_type: Option<&str>,
450-
) -> Result<Option<RequestBodyEncoding>, String> {
451-
if let Some(content_type) = request_content_type {
452-
let normalized = normalize_content_type(content_type);
453-
if content_type_is_json(&normalized) {
454-
return Ok(Some(RequestBodyEncoding::Json));
455-
}
456-
return Ok(Some(RequestBodyEncoding::Text));
422+
fn encode_request_payload(payload: &Value, content_type: &str) -> Result<Option<Vec<u8>>, String> {
423+
if matches!(payload.kind.as_ref(), Some(Kind::NullValue(_)) | None) {
424+
return Ok(None);
457425
}
458426

459-
match payload.kind.as_ref() {
460-
Some(Kind::NullValue(_)) | None => Ok(None),
461-
Some(Kind::StringValue(_)) => Ok(Some(RequestBodyEncoding::Text)),
462-
_ => Ok(Some(RequestBodyEncoding::Json)),
463-
}
427+
let format = format_for_content_type(content_type)?;
428+
let protobuf = serde_json::to_vec(payload)
429+
.map_err(|err| format!("Unable to serialize protobuf request payload: {err}"))?;
430+
let engine = ConversionEngine::with_default_codecs();
431+
let body = engine
432+
.convert(
433+
&protobuf,
434+
Format::Protobuf,
435+
format,
436+
&DecodeContext,
437+
&EncodeContext::default(),
438+
)
439+
.map_err(|err| {
440+
format!(
441+
"Unable to convert request payload to '{}': {err}",
442+
content_type
443+
)
444+
})?;
445+
Ok(Some(body))
464446
}
465447

466-
fn encode_request_payload(
467-
payload: &Value,
468-
request_content_type: Option<&str>,
469-
) -> Result<(Option<Vec<u8>>, Option<&'static str>), String> {
470-
let Some(encoding) = resolve_request_body_encoding(payload, request_content_type)? else {
471-
return Ok((None, None));
472-
};
473-
474-
match encoding {
475-
RequestBodyEncoding::Json => {
476-
let json = to_json_value(payload.clone());
477-
let body = serde_json::to_vec(&json)
478-
.map_err(|err| format!("Unable to serialize request payload: {}", err))?;
479-
Ok((Some(body), Some("application/json")))
448+
fn format_for_content_type(content_type: &str) -> Result<Format, String> {
449+
let normalized = normalize_content_type(content_type);
450+
let format = match normalized.as_str() {
451+
"application/json" | "text/json" => Format::Json,
452+
value if value.ends_with("+json") => Format::Json,
453+
"application/xhtml+xml" => Format::Html,
454+
"application/xml" | "text/xml" => Format::Xml,
455+
value if value.ends_with("+xml") => Format::Xml,
456+
"text/html" => Format::Html,
457+
"text/plain" => Format::Text,
458+
"text/csv" | "application/csv" => Format::Csv,
459+
"application/x-www-form-urlencoded" => Format::HttpForm,
460+
_ => {
461+
return Err(format!(
462+
"Unsupported content-type '{}' for http::request::send",
463+
content_type
464+
));
480465
}
481-
RequestBodyEncoding::Text => match payload.kind.as_ref() {
482-
Some(Kind::NullValue(_)) | None => Ok((None, Some("text/plain"))),
483-
Some(Kind::StringValue(body)) => {
484-
Ok((Some(body.as_bytes().to_vec()), Some("text/plain")))
485-
}
486-
_ => Err("Payload must be StringValue when content-type is not JSON".to_string()),
487-
},
488-
}
466+
};
467+
Ok(format)
489468
}
490469

491470
fn decode_headers(response: &http::Response<Body>) -> Struct {
@@ -555,88 +534,163 @@ mod tests {
555534
value.to_string().to_value()
556535
}
557536

558-
#[test]
559-
fn encode_request_payload_serializes_non_string_values_to_json() {
560-
let payload = Value {
561-
kind: Some(Kind::StructValue(Struct {
562-
fields: HashMap::from([(
563-
"ok".to_string(),
564-
Value {
565-
kind: Some(Kind::BoolValue(true)),
566-
},
567-
)]),
568-
})),
569-
};
537+
fn encoded_body(payload: &Value, content_type: &str) -> Vec<u8> {
538+
encode_request_payload(payload, content_type)
539+
.unwrap_or_else(|err| panic!("unable to encode {content_type} payload: {err}"))
540+
.unwrap_or_default()
541+
}
570542

571-
let (body, content_type) = match encode_request_payload(&payload, None) {
572-
Ok(result) => result,
573-
Err(err) => panic!("unexpected error: {}", err),
574-
};
543+
#[test]
544+
fn encode_request_payload_serializes_every_non_null_value_kind_to_json() {
545+
let cases = [
546+
(serde_json::json!(true), "true"),
547+
(serde_json::json!(42), "42"),
548+
(serde_json::json!(1.5), "1.5"),
549+
(serde_json::json!("hello"), r#""hello""#),
550+
(serde_json::json!(["hello", 42]), r#"["hello",42]"#),
551+
(
552+
serde_json::json!({"active": true, "name": "Tom"}),
553+
r#"{"active":true,"name":"Tom"}"#,
554+
),
555+
];
575556

576-
assert_eq!(content_type, Some("application/json"));
577-
let body = body.unwrap_or_default();
578-
let text = match String::from_utf8(body) {
579-
Ok(text) => text,
580-
Err(err) => panic!("payload was not valid utf8: {}", err),
581-
};
582-
let decoded = serde_json::from_str::<JsonValue>(&text).unwrap_or(JsonValue::Null);
583-
let JsonValue::Object(map) = decoded else {
584-
panic!("expected encoded payload to be json object");
585-
};
586-
let Some(JsonValue::Bool(ok)) = map.get("ok") else {
587-
panic!("missing ok field in json payload");
588-
};
589-
assert!(*ok);
557+
for (input, expected) in cases {
558+
let payload = from_json_value(input);
559+
assert_eq!(
560+
encoded_body(&payload, "application/json"),
561+
expected.as_bytes()
562+
);
563+
}
564+
}
590565

591-
let (empty_body, empty_content_type) = encode_request_payload(
592-
&Value {
566+
#[test]
567+
fn encode_request_payload_omits_null_and_missing_values() {
568+
for payload in [
569+
Value {
593570
kind: Some(Kind::NullValue(0)),
594571
},
595-
None,
596-
)
597-
.unwrap_or((Some(vec![1]), Some("application/json")));
598-
assert_eq!(empty_content_type, None);
599-
assert!(empty_body.is_none());
572+
Value { kind: None },
573+
] {
574+
assert_eq!(
575+
encode_request_payload(&payload, "application/json").unwrap_or(Some(vec![1])),
576+
None
577+
);
578+
}
600579
}
601580

602581
#[test]
603-
fn encode_request_payload_uses_text_for_non_json_content_type() {
604-
let (text_body, text_content_type) =
605-
encode_request_payload(&string_value("hello"), Some("text/plain; charset=utf-8"))
606-
.unwrap_or((None, None));
582+
fn encode_request_payload_does_not_detect_formats_inside_strings() {
583+
let text_body = encode_request_payload(&string_value("hello"), "text/plain; charset=utf-8")
584+
.unwrap_or(None);
607585

608-
assert_eq!(text_content_type, Some("text/plain"));
609586
let body = text_body.unwrap_or_default();
610587
assert_eq!(body, b"hello");
611588

612-
let (xml_body, xml_content_type) =
613-
encode_request_payload(&string_value("<ok />"), Some("application/xml"))
614-
.unwrap_or((None, None));
615-
assert_eq!(xml_content_type, Some("text/plain"));
616-
assert_eq!(xml_body.unwrap_or_default(), b"<ok />");
589+
let json_body =
590+
encode_request_payload(&string_value(r#"{"user":"tom"}"#), "application/json")
591+
.unwrap_or(None);
592+
assert_eq!(json_body.unwrap_or_default(), br#""{\"user\":\"tom\"}""#);
617593

618-
let (empty_body, empty_content_type) = encode_request_payload(
619-
&Value {
620-
kind: Some(Kind::NullValue(0)),
621-
},
622-
Some("application/octet-stream"),
623-
)
624-
.unwrap_or((Some(vec![1]), None));
625-
assert_eq!(empty_content_type, Some("text/plain"));
626-
assert!(empty_body.is_none());
627-
628-
let err = encode_request_payload(
629-
&Value {
630-
kind: Some(Kind::StructValue(Struct {
631-
fields: HashMap::from([("a".to_string(), 1i64.to_value())]),
632-
})),
633-
},
634-
Some("text/plain"),
594+
let xml_body =
595+
encode_request_payload(&string_value("<ok />"), "application/xml").unwrap_or(None);
596+
assert_eq!(xml_body.unwrap_or_default(), b"<data>&lt;ok /&gt;</data>");
597+
}
598+
599+
#[test]
600+
fn encode_request_payload_supports_every_http_target_format() {
601+
let object = from_json_value(serde_json::json!({
602+
"user": {
603+
"name": "Tom"
604+
}
605+
}));
606+
assert_eq!(
607+
encoded_body(&object, "application/json"),
608+
br#"{"user":{"name":"Tom"}}"#
609+
);
610+
assert_eq!(
611+
encoded_body(&object, "application/xml"),
612+
b"<user><name>Tom</name></user>"
613+
);
614+
assert_eq!(
615+
encoded_body(&object, "text/html"),
616+
b"<user><name>Tom</name></user>"
617+
);
618+
assert_eq!(
619+
encoded_body(&string_value("hello world"), "text/plain"),
620+
b"hello world"
635621
);
622+
623+
let rows = from_json_value(serde_json::json!([
624+
{"name": "Tom", "role": "admin"},
625+
{"name": "Ada", "role": "user"}
626+
]));
627+
assert_eq!(
628+
encoded_body(&rows, "text/csv"),
629+
b"name,role\nTom,admin\nAda,user\n"
630+
);
631+
632+
let form = from_json_value(serde_json::json!({
633+
"email": "tom@example.com",
634+
"name": "Tom Doe"
635+
}));
636+
assert_eq!(
637+
encoded_body(&form, "application/x-www-form-urlencoded"),
638+
b"email=tom%40example.com&name=Tom+Doe"
639+
);
640+
}
641+
642+
#[test]
643+
fn format_for_content_type_supports_parameters_and_standard_aliases() {
644+
let cases = [
645+
("application/json; charset=utf-8", Format::Json),
646+
("text/json", Format::Json),
647+
("application/problem+json", Format::Json),
648+
("application/xml; charset=utf-8", Format::Xml),
649+
("text/xml", Format::Xml),
650+
("application/atom+xml", Format::Xml),
651+
("text/html", Format::Html),
652+
("application/xhtml+xml", Format::Html),
653+
("text/plain; charset=utf-8", Format::Text),
654+
("text/csv", Format::Csv),
655+
("application/csv", Format::Csv),
656+
("application/x-www-form-urlencoded", Format::HttpForm),
657+
];
658+
659+
for (content_type, expected) in cases {
660+
assert_eq!(
661+
format_for_content_type(content_type),
662+
Ok(expected),
663+
"unexpected mapping for {content_type}"
664+
);
665+
}
666+
}
667+
668+
#[test]
669+
fn encode_request_payload_rejects_unsupported_content_types() {
670+
let err = encode_request_payload(&string_value("hello"), "application/octet-stream");
636671
let Err(err) = err else {
637-
panic!("expected text/plain payload validation error");
672+
panic!("expected unsupported content-type error");
638673
};
639-
assert!(err.contains("Payload must be StringValue"));
674+
assert!(err.contains("Unsupported content-type"));
675+
}
676+
677+
#[test]
678+
fn encode_request_payload_propagates_information_loss_errors() {
679+
let invalid_csv = from_json_value(serde_json::json!({
680+
"name": "Tom"
681+
}));
682+
let csv_error = encode_request_payload(&invalid_csv, "text/csv")
683+
.expect_err("object should not encode as CSV rows");
684+
assert!(csv_error.contains("conversion would lose information"));
685+
assert!(csv_error.contains("top-level array"));
686+
687+
let invalid_form = from_json_value(serde_json::json!({
688+
"active": true
689+
}));
690+
let form_error = encode_request_payload(&invalid_form, "application/x-www-form-urlencoded")
691+
.expect_err("typed form value should not be coerced to a string");
692+
assert!(form_error.contains("conversion would lose information"));
693+
assert!(form_error.contains("form fields must be strings"));
640694
}
641695

642696
#[test]
@@ -864,7 +918,10 @@ mod tests {
864918
})),
865919
};
866920
let request_headers = Struct {
867-
fields: HashMap::from([("x-bool".to_string(), true.to_value())]),
921+
fields: HashMap::from([
922+
("x-bool".to_string(), true.to_value()),
923+
("Content-Type".to_string(), string_value("text/plain")),
924+
]),
868925
};
869926
let args = vec![
870927
Argument::Eval(string_value("POST")),

0 commit comments

Comments
 (0)