use std::{sync::Arc, time::Duration};
use reqwest::{header::LOCATION, Method};
use rho_sdk::{
model::ToolSpec,
tool::{
AuthorizedToolContext, OperationKind, PreparedToolInvocation, Tool, ToolError,
ToolErrorKind, ToolInvocation, ToolMetadata, ToolOutput, ToolPreparationContext,
ToolPrepareFuture, ToolSecurity,
},
CapabilityKind, CapabilityRequest, CapabilitySource, NetworkTarget,
};
use serde_json::Value;
use url::{Origin, Url};
use super::{
config::SourceHeaders,
request::{PlannedRequest, RequestShape},
response::{self, BodyError, CallResult},
};
const MAX_REDIRECTS: usize = 10;
pub(crate) const USER_AGENT: &str = concat!("rho/", env!("CARGO_PKG_VERSION"));
#[derive(Debug)]
pub(crate) struct SourceClient {
pub(crate) name: String,
pub(crate) client: reqwest::Client,
pub(crate) origin: Origin,
pub(crate) headers: SourceHeaders,
pub(crate) timeout: Duration,
pub(crate) max_output_bytes: usize,
pub(crate) max_response_bytes: usize,
}
impl SourceClient {
pub(crate) fn http_client() -> reqwest::Result<reqwest::Client> {
crate::reqwest_client_builder()
.redirect(reqwest::redirect::Policy::none())
.user_agent(USER_AGENT)
.build()
}
}
#[derive(Debug)]
pub(crate) struct OpenApiTool {
pub(crate) spec: ToolSpec,
pub(crate) source: Arc<SourceClient>,
pub(crate) method: Method,
pub(crate) route: String,
pub(crate) base: Url,
pub(crate) path: String,
pub(crate) shape: RequestShape,
pub(crate) accept: Option<String>,
}
impl Tool for OpenApiTool {
fn spec(&self) -> ToolSpec {
self.spec.clone()
}
fn security(&self) -> ToolSecurity {
ToolSecurity::built_in([CapabilityKind::Network])
}
fn output_schema(&self) -> Option<Value> {
Some(rho_tools::output_schema::<CallResult>())
}
fn start_metadata(&self, _arguments: &Value) -> ToolMetadata {
ToolMetadata::new().operation(OperationKind::Network)
}
fn prepare<'a>(
&'a self,
invocation: ToolInvocation,
_context: ToolPreparationContext,
) -> ToolPrepareFuture<'a> {
let arguments = invocation.into_arguments();
Box::pin(async move {
let planned = self
.shape
.build(&self.base, &self.path, &arguments)
.map_err(|error| ToolError::new(ToolErrorKind::InvalidArguments, error))?;
let capability = CapabilityRequest::network(
NetworkTarget::Url(planned.url.to_string()),
CapabilitySource::built_in_tool(self.spec.name.clone()),
);
Ok(PreparedToolInvocation::resource_aware(
[],
[capability],
ToolMetadata::new().operation(OperationKind::Network),
move |context| Box::pin(self.execute(planned, arguments, context)),
))
})
}
}
impl OpenApiTool {
async fn execute(
&self,
planned: PlannedRequest,
arguments: Value,
context: AuthorizedToolContext,
) -> Result<ToolOutput, ToolError> {
let timeout = self.source.timeout;
let result = tokio::select! {
result = self.exchange(planned, &arguments) => result?,
() = context.cancellation().cancelled() => return Err(ToolError::cancelled()),
() = tokio::time::sleep(timeout) => {
return Err(self.failure(format!(
"timed out after {}s (timeout_secs)",
timeout.as_secs()
)));
}
};
let text = response::model_text(&self.route, &result, self.source.max_output_bytes);
let failed = !result.succeeded();
rho_tools::Rendered::new(text, result)
.failed_if(failed)
.into_tool_output(ToolMetadata::new().operation(OperationKind::Network))
}
async fn exchange(
&self,
planned: PlannedRequest,
arguments: &Value,
) -> Result<CallResult, ToolError> {
let PlannedRequest { url, headers, body } = planned;
let authorized = url.origin();
let trusted = authorized == self.source.origin;
let mut sent = headers;
if trusted {
for (name, value) in &self.source.headers.headers {
sent.insert(name, value.clone());
}
}
let mut url = url;
for _ in 0..=MAX_REDIRECTS {
let target = match (&self.source.headers.query, trusted) {
(Some(credential), true) => with_query_credential(&url, credential),
_ => url.clone(),
};
let mut request = self
.source
.client
.request(self.method.clone(), target)
.headers(sent.clone());
if let Some(accept) = &self.accept {
request = request.header(reqwest::header::ACCEPT, accept);
}
if let Some((media_type, bytes)) = &body {
request = request
.header(reqwest::header::CONTENT_TYPE, media_type)
.body(bytes.clone());
}
let response = request
.send()
.await
.map_err(|error| self.failure(error.without_url().to_string()))?;
if response.status().is_redirection() {
if let Some(location) = response.headers().get(LOCATION) {
let target = location
.to_str()
.ok()
.and_then(|location| url.join(location).ok())
.filter(|target| matches!(target.scheme(), "http" | "https"))
.ok_or_else(|| self.failure("redirected to an invalid location".into()))?;
if target.origin() != authorized {
return Err(self.failure(format!(
"could not follow redirect to {}: redirects to another origin are not followed",
target.origin().ascii_serialization()
)));
}
url = target;
continue;
}
}
return self.read(response, &url, arguments).await;
}
Err(self.failure(format!("followed more than {MAX_REDIRECTS} redirects")))
}
async fn read(
&self,
response: reqwest::Response,
url: &Url,
arguments: &Value,
) -> Result<CallResult, ToolError> {
let status = response.status().as_u16();
let headers = response.headers().clone();
let bytes = if self.method == Method::HEAD {
Vec::new()
} else {
response::read_body(response, self.source.max_response_bytes)
.await
.map_err(|error| match error {
BodyError::TooLarge { size, limit } => self.failure(format!(
"response body is {size} bytes, over max_response_bytes ({limit} bytes)"
)),
BodyError::Transport(error) => self.failure(error.without_url().to_string()),
})?
};
let credential = self.source.headers.query.as_ref().map(|(name, _)| name);
let next = response::next_link(&headers, url).and_then(|next| {
let pairs: Vec<(String, String)> = next
.query_pairs()
.into_owned()
.filter(|(name, _)| Some(name) != credential)
.collect();
self.shape.next_arguments(arguments, &pairs)
});
Ok(CallResult {
status,
body: response::decode_body(&headers, &bytes),
next,
headers: response::kept_headers(&headers),
})
}
fn failure(&self, problem: String) -> ToolError {
ToolError::new(
ToolErrorKind::Execution,
format!("{} ({}): {problem}", self.route, self.source.name),
)
}
}
fn with_query_credential(url: &Url, (name, value): &(String, String)) -> Url {
let kept: Vec<(String, String)> = url
.query_pairs()
.filter(|(key, _)| key != name)
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
let mut with_key = url.clone();
with_key
.query_pairs_mut()
.clear()
.extend_pairs(kept)
.append_pair(name, value);
with_key
}
#[cfg(test)]
#[path = "tool_tests.rs"]
mod tests;