#![cfg(feature = "client")]
use std::sync::OnceLock;
use std::time::SystemTime;
use serde_json::Value;
use crate::codec::{
HEADER_AUTHORIZATION, HEADER_WIRE_VERSION, Request, Response, WIRE_VERSION, path_for,
};
use crate::errors::{RpcError, RpcTransportError};
use crate::peers::{PeerEntry, shared_secret};
fn bearer(secret: Option<&str>) -> Option<String> {
secret.map(|s| format!("Bearer {s}"))
}
fn join_url(base_url: &str, interface: &str, method: &str) -> String {
let trimmed = base_url.trim_end_matches('/');
let path = path_for(interface, method);
format!("{trimmed}{path}")
}
fn check_status(status: u16, body: &str) -> Result<(), RpcTransportError> {
if status == 200 {
Ok(())
} else {
Err(RpcTransportError::BadStatus {
status,
body: body.chars().take(512).collect(),
})
}
}
pub fn dispatch_sync(
base_url: &str,
interface: &str,
method: &str,
args: Vec<Value>,
) -> Result<Value, RpcError> {
let request = Request::from_args(args);
let body = request.to_json_bytes();
let url = join_url(base_url, interface, method);
let client = reqwest::blocking::Client::new();
let mut req = client
.post(&url)
.header("Content-Type", "application/json")
.header(HEADER_WIRE_VERSION, WIRE_VERSION)
.body(body);
if let Some(b) = bearer(shared_secret().as_deref()) {
req = req.header(HEADER_AUTHORIZATION, b);
}
let resp = req
.send()
.map_err(|e| RpcTransportError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let bytes = resp
.bytes()
.map_err(|e| RpcTransportError::Http(e.to_string()))?;
let body_str = std::str::from_utf8(&bytes).unwrap_or("<non-utf8>");
check_status(status, body_str)?;
let envelope = Response::from_json_bytes(&bytes)?;
envelope.into_result().map_err(Into::into)
}
pub async fn dispatch_async(
base_url: &str,
interface: &str,
method: &str,
args: Vec<Value>,
) -> Result<Value, RpcError> {
let request = Request::from_args(args);
let body = request.to_json_bytes();
let url = join_url(base_url, interface, method);
let client = reqwest::Client::new();
let mut req = client
.post(&url)
.header("Content-Type", "application/json")
.header(HEADER_WIRE_VERSION, WIRE_VERSION)
.body(body);
if let Some(b) = bearer(shared_secret().as_deref()) {
req = req.header(HEADER_AUTHORIZATION, b);
}
let resp = req
.send()
.await
.map_err(|e| RpcTransportError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let bytes = resp
.bytes()
.await
.map_err(|e| RpcTransportError::Http(e.to_string()))?;
let body_str = std::str::from_utf8(&bytes).unwrap_or("<non-utf8>");
check_status(status, body_str)?;
let envelope = Response::from_json_bytes(&bytes)?;
envelope.into_result().map_err(Into::into)
}
pub fn dispatch_sync_by_peer(
peer: &PeerEntry,
interface: &str,
method: &str,
args: Vec<Value>,
) -> Result<Value, RpcError> {
match peer {
PeerEntry::Http { url } => dispatch_sync(url, interface, method, args),
PeerEntry::Lambda { function_url } => {
dispatch_sync_sigv4(function_url, interface, method, args)
}
PeerEntry::Inproc => Err(RpcTransportError::Http(
"caravan-rpc: inproc peer reached HTTP dispatch (internal bug)".to_string(),
)
.into()),
}
}
pub async fn dispatch_async_by_peer(
peer: &PeerEntry,
interface: &str,
method: &str,
args: Vec<Value>,
) -> Result<Value, RpcError> {
match peer {
PeerEntry::Http { url } => dispatch_async(url, interface, method, args).await,
PeerEntry::Lambda { function_url } => {
dispatch_async_sigv4(function_url, interface, method, args).await
}
PeerEntry::Inproc => Err(RpcTransportError::Http(
"caravan-rpc: inproc peer reached HTTP dispatch (internal bug)".to_string(),
)
.into()),
}
}
fn region_from_function_url(function_url: &str) -> Option<String> {
let after_scheme = function_url.split("://").nth(1)?;
let host = after_scheme.split('/').next()?;
let parts: Vec<&str> = host.split('.').collect();
if parts.len() >= 5 && parts[1] == "lambda-url" && parts[3] == "on" && parts[4] == "aws" {
Some(parts[2].to_string())
} else {
None
}
}
fn aws_config() -> &'static aws_config::SdkConfig {
static CONFIG: OnceLock<aws_config::SdkConfig> = OnceLock::new();
CONFIG.get_or_init(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("caravan-rpc: build current-thread runtime for aws_config init");
rt.block_on(async {
aws_config::defaults(aws_config::BehaviorVersion::latest())
.load()
.await
})
})
}
fn resolve_credentials() -> Result<aws_credential_types::Credentials, RpcTransportError> {
use aws_credential_types::provider::ProvideCredentials;
let cfg = aws_config();
let provider = cfg
.credentials_provider()
.ok_or_else(|| RpcTransportError::Http("aws credentials provider missing".into()))?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| RpcTransportError::Http(format!("build runtime: {e}")))?;
rt.block_on(provider.provide_credentials())
.map_err(|e| RpcTransportError::Http(format!("resolve aws credentials: {e}")))
}
fn sigv4_sign(
function_url: &str,
region: &str,
body: &[u8],
creds: &aws_credential_types::Credentials,
) -> Result<Vec<(String, String)>, RpcTransportError> {
use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings, sign};
use aws_sigv4::sign::v4;
let identity = creds.clone().into();
let signing_settings = SigningSettings::default();
let signing_params: aws_sigv4::http_request::SigningParams = v4::SigningParams::builder()
.identity(&identity)
.region(region)
.name("lambda")
.time(SystemTime::now())
.settings(signing_settings)
.build()
.map_err(|e| RpcTransportError::Http(format!("sigv4 params: {e}")))?
.into();
let signable = SignableRequest::new(
"POST",
function_url,
std::iter::empty(),
SignableBody::Bytes(body),
)
.map_err(|e| RpcTransportError::Http(format!("sigv4 signable: {e}")))?;
let (instructions, _signature) = sign(signable, &signing_params)
.map_err(|e| RpcTransportError::Http(format!("sigv4 sign: {e}")))?
.into_parts();
let (signing_headers, _params) = instructions.into_parts();
Ok(signing_headers
.into_iter()
.map(|h| (h.name().to_string(), h.value().to_string()))
.collect())
}
pub fn dispatch_sync_sigv4(
function_url: &str,
interface: &str,
method: &str,
args: Vec<Value>,
) -> Result<Value, RpcError> {
let request = Request::from_args(args);
let body = request.to_json_bytes();
let url = join_url(function_url, interface, method);
let region = region_from_function_url(function_url)
.ok_or_else(|| RpcTransportError::Http(format!("can't extract region from {url}")))?;
let creds = resolve_credentials()?;
let signed_headers = sigv4_sign(&url, ®ion, &body, &creds)?;
let client = reqwest::blocking::Client::new();
let mut req = client
.post(&url)
.header("Content-Type", "application/json")
.header(HEADER_WIRE_VERSION, WIRE_VERSION)
.body(body);
for (name, value) in signed_headers {
req = req.header(name, value);
}
let resp = req
.send()
.map_err(|e| RpcTransportError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let bytes = resp
.bytes()
.map_err(|e| RpcTransportError::Http(e.to_string()))?;
let body_str = std::str::from_utf8(&bytes).unwrap_or("<non-utf8>");
check_status(status, body_str)?;
let envelope = Response::from_json_bytes(&bytes)?;
envelope.into_result().map_err(Into::into)
}
pub async fn dispatch_async_sigv4(
function_url: &str,
interface: &str,
method: &str,
args: Vec<Value>,
) -> Result<Value, RpcError> {
let request = Request::from_args(args);
let body = request.to_json_bytes();
let url = join_url(function_url, interface, method);
let region = region_from_function_url(function_url)
.ok_or_else(|| RpcTransportError::Http(format!("can't extract region from {url}")))?;
let creds = tokio::task::spawn_blocking(resolve_credentials)
.await
.map_err(|e| RpcTransportError::Http(format!("join creds task: {e}")))??;
let signed_headers = sigv4_sign(&url, ®ion, &body, &creds)?;
let client = reqwest::Client::new();
let mut req = client
.post(&url)
.header("Content-Type", "application/json")
.header(HEADER_WIRE_VERSION, WIRE_VERSION)
.body(body);
for (name, value) in signed_headers {
req = req.header(name, value);
}
let resp = req
.send()
.await
.map_err(|e| RpcTransportError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let bytes = resp
.bytes()
.await
.map_err(|e| RpcTransportError::Http(e.to_string()))?;
let body_str = std::str::from_utf8(&bytes).unwrap_or("<non-utf8>");
check_status(status, body_str)?;
let envelope = Response::from_json_bytes(&bytes)?;
envelope.into_result().map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_joining_trims_trailing_slash() {
assert_eq!(
join_url("http://embedder:8080/", "Embedder", "embed_one"),
"http://embedder:8080/_caravan/rpc/Embedder/embed_one"
);
assert_eq!(
join_url("http://embedder:8080", "Embedder", "embed_one"),
"http://embedder:8080/_caravan/rpc/Embedder/embed_one"
);
}
#[test]
fn bearer_header_format() {
assert_eq!(bearer(Some("hex123")).as_deref(), Some("Bearer hex123"));
assert_eq!(bearer(None), None);
}
#[test]
fn check_status_ok() {
assert!(check_status(200, "ok").is_ok());
}
#[test]
fn check_status_5xx_returns_bad_status() {
let err = check_status(500, "boom").unwrap_err();
match err {
RpcTransportError::BadStatus { status, body } => {
assert_eq!(status, 500);
assert_eq!(body, "boom");
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn check_status_truncates_large_body() {
let big_body = "x".repeat(2000);
let err = check_status(500, &big_body).unwrap_err();
match err {
RpcTransportError::BadStatus { body, .. } => assert_eq!(body.len(), 512),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn region_extracted_from_function_url() {
assert_eq!(
region_from_function_url("https://abc123.lambda-url.us-east-1.on.aws/").as_deref(),
Some("us-east-1")
);
assert_eq!(
region_from_function_url(
"https://abc123.lambda-url.ap-southeast-2.on.aws/_caravan/rpc/X/m"
)
.as_deref(),
Some("ap-southeast-2")
);
assert_eq!(
region_from_function_url("https://example.com/").as_deref(),
None
);
}
}