use std::time::SystemTime;
use vantage_core::Result;
use vantage_core::error;
use crate::account::AwsAccount;
use crate::sign::sign_v4;
pub(crate) async fn query_call(
account: &AwsAccount,
service: &str,
form: &[(String, String)],
) -> Result<String> {
let configured_region = account.region();
if configured_region.is_empty() && !is_global_service(service) {
return Err(error!(
"AWS region is not configured — pass it to AwsAccount::new \
or set AWS_REGION before calling AwsAccount::from_env"
));
}
let (host, signing_region) = if is_global_service(service) {
(format!("{service}.amazonaws.com"), "us-east-1")
} else {
(
format!("{service}.{configured_region}.amazonaws.com"),
configured_region,
)
};
let url = format!("https://{host}/");
let body = form_encode(form);
let body_bytes = body.into_bytes();
let signing_headers = [
("host".to_string(), host.clone()),
(
"content-type".to_string(),
"application/x-www-form-urlencoded; charset=utf-8".to_string(),
),
];
let signed = sign_v4(
account.access_key(),
account.secret_key(),
account.session_token(),
signing_region,
service,
"POST",
&url,
&signing_headers,
&body_bytes,
SystemTime::now(),
)?;
let mut req = account
.http()
.post(&url)
.header(
"content-type",
"application/x-www-form-urlencoded; charset=utf-8",
)
.body(body_bytes);
for h in &signed {
req = req.header(h.name.as_str(), h.value.as_str());
}
let resp = req.send().await.map_err(|e| {
error!(
"AWS Query request failed",
url = url.as_str(),
service = service,
detail = e
)
})?;
let status = resp.status();
let response_text = resp
.text()
.await
.map_err(|e| error!("Failed to read AWS Query response body", detail = e))?;
if !status.is_success() {
return Err(error!(
"AWS Query request returned error status",
service = service,
status = status.as_u16(),
body = response_text
));
}
Ok(response_text)
}
fn is_global_service(service: &str) -> bool {
matches!(service, "iam")
}
fn form_encode(pairs: &[(String, String)]) -> String {
pairs
.iter()
.map(|(k, v)| format!("{}={}", form_encode_part(k), form_encode_part(v)))
.collect::<Vec<_>>()
.join("&")
}
fn form_encode_part(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
if unreserved {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn form_encode_orders_pairs_as_given() {
let body = form_encode(&[
("Action".into(), "ListUsers".into()),
("Version".into(), "2010-05-08".into()),
]);
assert_eq!(body, "Action=ListUsers&Version=2010-05-08");
}
#[test]
fn form_encode_escapes_reserved() {
let body = form_encode(&[("Path".into(), "/admin/team a/".into())]);
assert_eq!(body, "Path=%2Fadmin%2Fteam%20a%2F");
}
#[test]
fn form_encode_handles_plus_and_amp() {
let body = form_encode(&[("X".into(), "a+b&c".into())]);
assert_eq!(body, "X=a%2Bb%26c");
}
}