Skip to content

Commit

Permalink
support application/x-www-form-urlencoded request bodies (#5869)
Browse files Browse the repository at this point in the history
Co-authored-by: Dylan Anthony <dylan@apollographql.com>
  • Loading branch information
lennyburdette and dylan-apollo authored Aug 26, 2024
1 parent 391e85b commit 64f4a60
Show file tree
Hide file tree
Showing 10 changed files with 541 additions and 43 deletions.
2 changes: 2 additions & 0 deletions Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ dependencies = [
"displaydoc",
"ecdsa",
"flate2",
"form_urlencoded",
"fred",
"futures",
"futures-test",
Expand Down Expand Up @@ -344,6 +345,7 @@ dependencies = [
"router-bridge",
"rowan",
"rstack",
"rstest",
"rust-embed",
"rustls",
"rustls-native-certs",
Expand Down
2 changes: 2 additions & 0 deletions apollo-router/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ bytesize = { version = "1.3.0", features = ["serde"] }
ahash = "0.8.11"
itoa = "1.0.9"
ryu = "1.0.15"
form_urlencoded = "1.2.1"

[target.'cfg(macos)'.dependencies]
uname = "0.1.1"
Expand Down Expand Up @@ -350,6 +351,7 @@ tracing-test = "0.2.5"
walkdir = "2.5.0"
wiremock = "0.5.22"
libtest-mimic = "0.7.3"
rstest = "0.22.0"

[target.'cfg(target_os = "linux")'.dev-dependencies]
rstack = { version = "0.3.3", features = ["dw"], default-features = false }
Expand Down
136 changes: 136 additions & 0 deletions apollo-router/src/plugins/connectors/form_encoding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
use serde_json_bytes::Value;

pub(super) fn encode_json_as_form(value: &Value) -> Result<String, &'static str> {
if value.as_object().is_none() {
return Err("Expected URL-encoded forms to be objects");
}

let mut encoded: form_urlencoded::Serializer<String> =
form_urlencoded::Serializer::new(String::new());

fn encode(encoded: &mut form_urlencoded::Serializer<String>, value: &Value, prefix: &str) {
match value {
Value::Null => {
encoded.append_pair(prefix, "");
}
Value::String(s) => {
encoded.append_pair(prefix, s.as_str());
}
Value::Bool(b) => {
encoded.append_pair(prefix, if *b { "true" } else { "false" });
}
Value::Number(n) => {
encoded.append_pair(prefix, &n.to_string());
}
Value::Array(array) => {
for (i, value) in array.iter().enumerate() {
let prefix = format!("{prefix}[{i}]");
encode(encoded, value, &prefix);
}
}
Value::Object(obj) => {
for (key, value) in obj {
if prefix.is_empty() {
encode(encoded, value, key.as_str())
} else {
let prefix = format!("{prefix}[{key}]", key = key.as_str());
encode(encoded, value, &prefix);
};
}
}
}
}

encode(&mut encoded, value, "");

Ok(encoded.finish())
}

#[cfg(test)]
mod tests {
use serde_json_bytes::json;

use super::*;

#[test]
fn complex() {
let data = json!({
"a": 1,
"b": "2",
"c": {
"d": 3,
"e": "4",
"f": {
"g": 5,
"h": "6",
"i": [7, 8, 9],
"j": [
{"k": 10},
{"l": 11},
{"m": 12}
]
}
}
});

let encoded = encode_json_as_form(&data).expect("test case is valid for transformation");
assert_eq!(encoded, "a=1&b=2&c%5Bd%5D=3&c%5Be%5D=4&c%5Bf%5D%5Bg%5D=5&c%5Bf%5D%5Bh%5D=6&c%5Bf%5D%5Bi%5D%5B0%5D=7&c%5Bf%5D%5Bi%5D%5B1%5D=8&c%5Bf%5D%5Bi%5D%5B2%5D=9&c%5Bf%5D%5Bj%5D%5B0%5D%5Bk%5D=10&c%5Bf%5D%5Bj%5D%5B1%5D%5Bl%5D=11&c%5Bf%5D%5Bj%5D%5B2%5D%5Bm%5D=12");
}

// https://github.com/ljharb/qs/blob/main/test/stringify.js used as reference for these tests
#[rstest::rstest]
#[case(r#"{ "a": "b" }"#, "a=b")]
#[case(r#"{ "a": 1 }"#, "a=1")]
#[case(r#"{ "a": 1, "b": 2 }"#, "a=1&b=2")]
#[case(r#"{ "a": "A_Z" }"#, "a=A_Z")]
#[case(r#"{ "a": "€" }"#, "a=%E2%82%AC")]
#[case(r#"{ "a": "" }"#, "a=%EE%80%80")]
#[case(r#"{ "a": "א" }"#, "a=%D7%90")]
#[case(r#"{ "a": "𐐷" }"#, "a=%F0%90%90%B7")]
#[case(r#"{ "a": { "b": "c" } }"#, "a%5Bb%5D=c")]
#[case(
r#"{ "a": { "b": { "c": { "d": "e" } } } }"#,
"a%5Bb%5D%5Bc%5D%5Bd%5D=e"
)]
#[case(r#"{ "a": ["b", "c", "d"] }"#, "a%5B0%5D=b&a%5B1%5D=c&a%5B2%5D=d")]
#[case(r#"{ "a": [], "b": "zz" }"#, "b=zz")]
#[case(
r#"{ "a": { "b": ["c", "d"] } }"#,
"a%5Bb%5D%5B0%5D=c&a%5Bb%5D%5B1%5D=d"
)]
#[case(
r#"{ "a": [",", "", "c,d%"] }"#,
"a%5B0%5D=%2C&a%5B1%5D=&a%5B2%5D=c%2Cd%25"
)]
#[case(r#"{ "a": ",", "b": "", "c": "c,d%" }"#, "a=%2C&b=&c=c%2Cd%25")]
#[case(r#"{ "a": [{ "b": "c" }] }"#, "a%5B0%5D%5Bb%5D=c")]
#[case(
r#"{ "a": [{ "b": { "c": [1] } }] }"#,
"a%5B0%5D%5Bb%5D%5Bc%5D%5B0%5D=1"
)]
#[case(
r#"{ "a": [{ "b": 1 }, 2, 3] }"#,
"a%5B0%5D%5Bb%5D=1&a%5B1%5D=2&a%5B2%5D=3"
)]
#[case(r#"{ "a": "" }"#, "a=")]
#[case(r#"{ "a": null }"#, "a=")]
#[case(r#"{ "a": { "b": "" } }"#, "a%5Bb%5D=")]
#[case(r#"{ "a": { "b": null } }"#, "a%5Bb%5D=")]
#[case(r#"{ "a": "b c" }"#, "a=b+c")] // RFC 1738, not RFC 3986 with %20 for spaces!
#[case(
r#"{ "my weird field": "~q1!2\"'w$5&7/z8)?" }"#,
// "my%20weird%20field=~q1%212%22%27w%245%267%2Fz8%29%3F"
"my+weird+field=%7Eq1%212%22%27w%245%267%2Fz8%29%3F"
)]
#[case(r#"{ "a": true }"#, "a=true")]
#[case(r#"{ "a": { "b": true } }"#, "a%5Bb%5D=true")]
#[case(r#"{ "b": false }"#, "b=false")]
#[case(r#"{ "b": { "c": false } }"#, "b%5Bc%5D=false")]
// #[case(r#"{ "a": [, "2", , , "1"] }"#, "a%5B1%5D=2&a%5B4%5D=1")] // json doesn't do sparse arrays

fn stringifies_a_querystring_object(#[case] json: &str, #[case] expected: &str) {
let json = serde_json::from_slice::<Value>(json.as_bytes()).unwrap();
let encoded = encode_json_as_form(&json).expect("test cases are valid for transformation");
assert_eq!(encoded, expected);
}
}
122 changes: 84 additions & 38 deletions apollo-router/src/plugins/connectors/http_json_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use serde_json_bytes::Value;
use thiserror::Error;
use url::Url;

use super::form_encoding::encode_json_as_form;
use crate::plugins::connectors::plugin::ConnectorContext;
use crate::plugins::connectors::plugin::SelectionData;
use crate::services::connect;
Expand Down Expand Up @@ -77,43 +78,74 @@ pub(crate) fn make_request(
&flat_inputs,
)?;

let (json_body, body, apply_to_errors) = if let Some(ref selection) = transport.body {
let request = http::Request::builder()
.method(transport.method.as_str())
.uri(uri.as_str());

// add the headers and if content-type is specified, we'll check that when constructing the body
let (mut request, content_type) = add_headers(
request,
original_request.supergraph_request.headers(),
&transport.headers,
&flat_inputs,
);

let is_form_urlencoded = content_type.as_ref() == Some(&mime::APPLICATION_WWW_FORM_URLENCODED);

let (json_body, form_body, body, apply_to_errors) = if let Some(ref selection) = transport.body
{
let (json_body, apply_to_errors) = selection.apply_with_vars(&json!({}), &inputs);
let mut form_body = None;
let body = if let Some(json_body) = json_body.as_ref() {
hyper::Body::from(serde_json::to_vec(json_body)?)
if is_form_urlencoded {
let encoded = encode_json_as_form(json_body)
.map_err(HttpJsonTransportError::FormBodySerialization)?;
form_body = Some(encoded.clone());
hyper::Body::from(encoded)
} else {
request = request.header(CONTENT_TYPE, mime::APPLICATION_JSON.essence_str());
hyper::Body::from(serde_json::to_vec(json_body)?)
}
} else {
hyper::Body::empty()
};
(json_body, body, apply_to_errors)
(json_body, form_body, body, apply_to_errors)
} else {
(None, hyper::Body::empty(), vec![])
(None, None, hyper::Body::empty(), vec![])
};

let mut request = http::Request::builder()
.method(transport.method.as_str())
.uri(uri.as_str())
.header("content-type", "application/json")
let request = request
.body(body.into())
.map_err(HttpJsonTransportError::InvalidNewRequest)?;

add_headers(
&mut request,
original_request.supergraph_request.headers(),
&transport.headers,
&flat_inputs,
);

if let Some(debug) = debug {
debug.lock().push_request(
&request,
json_body.as_ref(),
transport.body.as_ref().map(|body| SelectionData {
source: body.to_string(),
transformed: body.to_string(),
result: json_body.clone(),
errors: apply_to_errors,
}),
);
if is_form_urlencoded {
debug.lock().push_request(
&request,
"form-urlencoded".to_string(),
form_body
.map(|s| serde_json_bytes::Value::String(s.clone().into()))
.as_ref(),
transport.body.as_ref().map(|body| SelectionData {
source: body.to_string(),
transformed: body.to_string(), // no transformation so this is the same
result: json_body,
errors: apply_to_errors,
}),
);
} else {
debug.lock().push_request(
&request,
"json".to_string(),
json_body.as_ref(),
transport.body.as_ref().map(|body| SelectionData {
source: body.to_string(),
transformed: body.to_string(), // no transformation so this is the same
result: json_body.clone(),
errors: apply_to_errors,
}),
);
}
}

Ok(request)
Expand Down Expand Up @@ -172,13 +204,14 @@ fn flatten_keys_recursive(inputs: &Value, flat: &mut Map<ByteString, Value>, pre
}

#[allow(clippy::mutable_key_type)] // HeaderName is internally mutable, but safe to use in maps
fn add_headers<T>(
request: &mut http::Request<T>,
fn add_headers(
mut request: http::request::Builder,
incoming_supergraph_headers: &HeaderMap<HeaderValue>,
config: &IndexMap<HeaderName, HeaderSource>,
inputs: &Map<ByteString, Value>,
) {
let headers = request.headers_mut();
) -> (http::request::Builder, Option<mime::Mime>) {
let mut content_type = None;

for (header_name, header_source) in config {
match header_source {
HeaderSource::From(from) => {
Expand All @@ -191,7 +224,7 @@ fn add_headers<T>(
let values = incoming_supergraph_headers.get_all(from);
let mut propagated = false;
for value in values {
headers.append(header_name.clone(), value.clone());
request = request.header(header_name.clone(), value.clone());
propagated = true;
}
if !propagated {
Expand All @@ -202,7 +235,11 @@ fn add_headers<T>(
HeaderSource::Value(value) => match value.interpolate(inputs) {
Ok(value) => match HeaderValue::from_str(value.as_str()) {
Ok(value) => {
headers.append(header_name, value);
request = request.header(header_name, value.clone());

if header_name == CONTENT_TYPE {
content_type = Some(value.clone());
}
}
Err(err) => {
tracing::error!("Invalid header value '{:?}': {:?}", value, err);
Expand All @@ -214,6 +251,11 @@ fn add_headers<T>(
},
}
}

(
request,
content_type.and_then(|v| v.to_str().unwrap_or_default().parse().ok()),
)
}

#[derive(Error, Display, Debug)]
Expand All @@ -223,7 +265,9 @@ pub(crate) enum HttpJsonTransportError {
/// Could not generate HTTP request: {0}
InvalidNewRequest(#[source] http::Error),
/// Could not serialize body: {0}
BodySerialization(#[from] serde_json::Error),
JsonBodySerialization(#[from] serde_json::Error),
/// Could not serialize body: {0}
FormBodySerialization(&'static str),
/// Error building URI: {0:?}
InvalidUrl(url::ParseError),
/// Could not generate path from inputs: {0}
Expand Down Expand Up @@ -728,13 +772,14 @@ mod tests {
.into_iter()
.collect();

let mut request = http::Request::builder().body(hyper::Body::empty()).unwrap();
add_headers(
&mut request,
let request = http::Request::builder();
let (request, _) = add_headers(
request,
&incoming_supergraph_headers,
&IndexMap::with_hasher(Default::default()),
&Map::default(),
);
let request = request.body(hyper::Body::empty()).unwrap();
assert!(request.headers().is_empty());
}

Expand All @@ -760,13 +805,14 @@ mod tests {
HeaderSource::Value("inserted".parse().unwrap()),
);

let mut request = http::Request::builder().body(hyper::Body::empty()).unwrap();
add_headers(
&mut request,
let request = http::Request::builder();
let (request, _) = add_headers(
request,
&incoming_supergraph_headers,
&config,
&Map::default(),
);
let request = request.body(hyper::Body::empty()).unwrap();
let result = request.headers();
assert_eq!(result.len(), 3);
assert_eq!(result.get("x-new-name"), Some(&"renamed".parse().unwrap()));
Expand Down
4 changes: 1 addition & 3 deletions apollo-router/src/plugins/connectors/make_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1660,9 +1660,7 @@ mod tests {
method: GET,
uri: http://localhost/api/path,
version: HTTP/1.1,
headers: {
"content-type": "application/json",
},
headers: {},
body: Body(
Empty,
),
Expand Down
1 change: 1 addition & 0 deletions apollo-router/src/plugins/connectors/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub(crate) mod configuration;
pub(crate) mod error;
mod form_encoding;
pub(crate) mod handle_responses;
pub(crate) mod http;
pub(crate) mod http_json_transport;
Expand Down
Loading

0 comments on commit 64f4a60

Please sign in to comment.