use std::{
collections::{BTreeMap, HashSet},
sync::Arc,
};
use reqwest::Method;
use rho_sdk::{model::ToolSpec, tool::Tool};
use url::Url;
use super::{
config::{SourceFailure, SourcePlan, SourcePlans},
fetch::{SpecCache, SpecFetch},
operation::{HttpMethod, Operation, ResponseBody},
report::{LoadedSource, OpenApiReport},
request::RequestShape,
response::MAX_RESPONSE_BYTES,
tool::{OpenApiTool, SourceClient},
OpenApiSpec, SpecInput, DEFAULT_MAX_SPEC_BYTES,
};
use crate::tools::{code_mode::ContextExposure, sdk_registry::ToolBundle};
pub(crate) struct OpenApiBundle {
tools: Vec<Arc<dyn Tool>>,
}
impl ToolBundle for OpenApiBundle {
fn tools(&self) -> &[Arc<dyn Tool>] {
&self.tools
}
fn context_exposure(&self) -> ContextExposure {
ContextExposure::Hidden
}
}
#[derive(Default)]
pub(crate) struct OpenApiLoadOutcome {
pub(crate) bundle: Option<OpenApiBundle>,
pub(crate) report: OpenApiReport,
}
pub(crate) struct LoadOptions {
pub(crate) cache: Option<SpecCache>,
pub(crate) max_output_bytes: usize,
pub(crate) max_response_bytes: usize,
}
impl LoadOptions {
pub(crate) fn new(max_output_bytes: usize) -> Self {
Self {
cache: SpecCache::default_dir().map(SpecCache::new),
max_output_bytes,
max_response_bytes: MAX_RESPONSE_BYTES,
}
}
}
pub(crate) async fn load(plans: SourcePlans, options: LoadOptions) -> OpenApiLoadOutcome {
let SourcePlans {
sources,
failures,
warnings,
} = plans;
let mut report = OpenApiReport {
sources: Vec::new(),
errors: failures.into_iter().map(failure_message).collect(),
warnings,
};
if sources.is_empty() {
return OpenApiLoadOutcome {
bundle: None,
report,
};
}
let clients = SourceClient::http_client().and_then(|calls| {
let specs = crate::reqwest_client_builder()
.user_agent(super::tool::USER_AGENT)
.build()?;
Ok((calls, specs))
});
let (calls, specs) = match clients {
Ok(clients) => clients,
Err(error) => {
report.errors.push(format!(
"could not load OpenAPI sources: could not build an HTTP client: {error}"
));
return OpenApiLoadOutcome {
bundle: None,
report,
};
}
};
let jobs = sources
.into_iter()
.map(|plan| load_source(plan, &calls, &specs, &options));
let mut tools: Vec<Arc<dyn Tool>> = Vec::new();
let mut names = HashSet::new();
for (name, result) in futures_util::future::join_all(jobs).await {
match result {
Ok(loaded) => {
report.warnings.extend(loaded.warning);
for tool in loaded.tools {
let tool_name = tool.spec().name;
if names.insert(tool_name.clone()) {
tools.push(tool);
} else {
report.warnings.push(format!(
"OpenAPI tool `{tool_name}` is declared twice; keeping the first"
));
}
}
report.sources.push(loaded.source);
}
Err(error) => report
.errors
.push(format!("could not load OpenAPI source `{name}`: {error}")),
}
}
OpenApiLoadOutcome {
bundle: (!tools.is_empty()).then_some(OpenApiBundle { tools }),
report,
}
}
fn failure_message(failure: SourceFailure) -> String {
match failure.name {
Some(name) => format!("could not load OpenAPI source `{name}`: {}", failure.error),
None => format!("could not load OpenAPI sources: {}", failure.error),
}
}
struct Loaded {
tools: Vec<Arc<dyn Tool>>,
source: LoadedSource,
warning: Option<String>,
}
async fn load_source(
plan: SourcePlan,
calls: &reqwest::Client,
specs: &reqwest::Client,
options: &LoadOptions,
) -> (String, Result<Loaded, String>) {
let name = plan.name.clone();
let fetch = SpecFetch {
client: specs,
cache: options.cache.as_ref(),
timeout: plan.timeout,
max_spec_bytes: DEFAULT_MAX_SPEC_BYTES,
};
let fetched = match fetch.fetch(&plan.spec).await {
Ok(fetched) => fetched,
Err(error) => return (name, Err(error)),
};
let cache = options.cache.clone();
let source = SourceSettings {
calls: calls.clone(),
max_output_bytes: options.max_output_bytes,
max_response_bytes: options.max_response_bytes,
};
let built = tokio::task::spawn_blocking(move || {
let spec = OpenApiSpec::load(SpecInput {
name: &plan.name,
url: &fetched.url,
bytes: &fetched.bytes,
max_spec_bytes: DEFAULT_MAX_SPEC_BYTES,
})
.map_err(|error| error.to_string())?;
if let Some(cache) = &cache {
cache.remember(&fetched);
}
for skipped in &spec.skipped {
tracing::debug!(
source = %plan.name,
path = %skipped.path,
reason = %skipped.reason,
"OpenAPI operation skipped"
);
}
let (tools, source) = build_tools(&plan, &spec, source);
Ok(Loaded {
tools,
source,
warning: fetched
.warning
.map(|warning| format!("OpenAPI source `{}`: {warning}", plan.name)),
})
})
.await
.unwrap_or_else(|error| Err(format!("loading stopped: {error}")));
(name, built)
}
struct SourceSettings {
calls: reqwest::Client,
max_output_bytes: usize,
max_response_bytes: usize,
}
fn build_tools(
plan: &SourcePlan,
spec: &OpenApiSpec,
settings: SourceSettings,
) -> (Vec<Arc<dyn Tool>>, LoadedSource) {
let default_server = spec.servers.iter().find_map(|server| server.as_ref().ok());
let origin = plan
.base_url
.as_ref()
.or(default_server)
.map_or_else(url::Origin::new_opaque, Url::origin);
let source = Arc::new(SourceClient {
name: plan.name.clone(),
client: settings.calls,
origin,
headers: plan.headers.clone(),
timeout: plan.timeout,
max_output_bytes: settings.max_output_bytes,
max_response_bytes: settings.max_response_bytes,
});
let mut tools: Vec<Arc<dyn Tool>> = Vec::new();
let mut tag_counts: BTreeMap<&str, usize> = BTreeMap::new();
for operation in &spec.operations {
let method = match operation.method {
HttpMethod::Get => Method::GET,
HttpMethod::Head => Method::HEAD,
HttpMethod::Put
| HttpMethod::Post
| HttpMethod::Delete
| HttpMethod::Options
| HttpMethod::Patch
| HttpMethod::Trace => continue,
};
if !plan.filter.admits(operation) {
continue;
}
let base = plan.base_url.as_ref().or_else(|| match &operation.servers {
Some(servers) => servers.iter().find_map(|server| server.as_ref().ok()),
None => default_server,
});
let Some(base) = base else {
tracing::debug!(source = %plan.name, path = %operation.path, "OpenAPI operation has no usable server");
continue;
};
let (shape, input_schema) = match RequestShape::new(operation, &spec.document) {
Ok(built) => built,
Err(error) => {
tracing::debug!(source = %plan.name, path = %operation.path, error = %error, "OpenAPI operation schema did not render");
continue;
}
};
let route = format!("{} {}", method.as_str(), operation.path);
for tag in &operation.tags {
*tag_counts.entry(tag).or_default() += 1;
}
tools.push(Arc::new(OpenApiTool {
spec: ToolSpec {
name: operation.tool_name.clone(),
description: description(&plan.name, &route, operation),
input_schema,
},
source: source.clone(),
method,
route,
base: base.clone(),
path: operation.path.clone(),
shape,
accept: accept(operation),
}));
}
let mut tags: Vec<(&str, usize)> = tag_counts.into_iter().collect();
tags.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(right.0)));
let loaded = LoadedSource {
name: plan.name.clone(),
read_operations: tools.len(),
tags: tags.into_iter().map(|(tag, _)| tag.to_owned()).collect(),
};
(tools, loaded)
}
fn description(source: &str, route: &str, operation: &Operation) -> String {
let mut text = String::new();
if let Some(summary) = operation.summary.as_deref().map(str::trim) {
if !summary.is_empty() {
text.push_str(summary);
text.push_str("\n\n");
}
}
text.push_str(&format!("{route} on OpenAPI source `{source}`"));
if !operation.tags.is_empty() {
text.push_str(&format!(" (tags: {})", operation.tags.join(", ")));
}
text.push('.');
text
}
fn accept(operation: &Operation) -> Option<String> {
operation
.responses
.iter()
.find_map(|response| match &response.body {
ResponseBody::Json(content) if content.media_type == "*/*" => {
Some("application/json".to_owned())
}
ResponseBody::Json(content) => Some(content.media_type.clone()),
ResponseBody::Text { media_type } => Some(media_type.clone()),
ResponseBody::Empty => None,
})
}
#[cfg(test)]
#[path = "bundle_tests.rs"]
mod tests;