1use std::{sync::Arc, time::Duration};
48
49use async_trait::async_trait;
50use serde_json::Value;
51use soma_provider_core::{
52 Provider, ProviderCall, ProviderCatalog, ProviderError, ProviderOutput, ProviderTool,
53};
54use url::Url;
55
56fn ensure_rustls_crypto_provider() {
62 static INSTALL: std::sync::Once = std::sync::Once::new();
63 INSTALL.call_once(|| {
64 let _ = rustls::crypto::ring::default_provider().install_default();
65 });
66}
67
68fn dispatch_client() -> Result<reqwest::Client, reqwest::Error> {
77 reqwest::Client::builder()
78 .redirect(reqwest::redirect::Policy::none())
79 .connect_timeout(Duration::from_secs(10))
80 .timeout(Duration::from_secs(30))
81 .build()
82}
83
84#[derive(Clone)]
85pub struct OpenApiProvider {
86 catalog: ProviderCatalog,
87}
88
89impl OpenApiProvider {
90 pub fn curated(catalog: ProviderCatalog) -> Self {
91 Self { catalog }
92 }
93
94 pub fn arc(catalog: ProviderCatalog) -> Arc<Self> {
95 Arc::new(Self::curated(catalog))
96 }
97}
98
99#[async_trait]
100impl Provider for OpenApiProvider {
101 fn catalog(&self) -> ProviderCatalog {
102 self.catalog.clone()
103 }
104
105 async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
106 ensure_rustls_crypto_provider();
107 let tool = self.tool(&call)?;
108 let operation = OpenApiOperation::from_catalog(&self.catalog, tool, &call)?;
109 if !call.params.is_object() {
110 return Err(ProviderError::validation(
111 &self.catalog.provider.name,
112 &call.action,
113 "openapi_params_must_be_object",
114 "OpenAPI provider params must be a JSON object",
115 ));
116 }
117
118 let client = dispatch_client().map_err(|error| {
119 ProviderError::opaque_execution(&self.catalog.provider.name, &call.action, error)
120 })?;
121 let handle = soma_openapi::registry::OperationHandle {
122 operation_id: call.action.clone(),
123 method: operation.method,
124 path_template: operation.path,
125 base_url: operation.base,
126 credential: None,
127 };
128 let value = soma_openapi::http::execute_operation_for_allowlisted_host(
129 &client,
130 &handle,
131 call.params,
132 )
133 .await
134 .map_err(|error| map_openapi_error(&self.catalog.provider.name, &call.action, error))?;
135 Ok(ProviderOutput::json(value))
136 }
137}
138
139impl OpenApiProvider {
140 fn tool(&self, call: &ProviderCall) -> Result<&ProviderTool, ProviderError> {
141 self.catalog
142 .tools
143 .iter()
144 .find(|tool| tool.name == call.action)
145 .ok_or_else(|| {
146 ProviderError::validation(
147 &self.catalog.provider.name,
148 &call.action,
149 "unknown_openapi_action",
150 format!("OpenAPI provider has no action `{}`", call.action),
151 )
152 })
153 }
154}
155
156struct OpenApiOperation {
157 method: reqwest::Method,
158 base: Url,
159 path: String,
160}
161
162impl OpenApiOperation {
163 fn from_catalog(
164 catalog: &ProviderCatalog,
165 tool: &ProviderTool,
166 call: &ProviderCall,
167 ) -> Result<Self, ProviderError> {
168 let base_url = catalog
169 .meta
170 .get("openapi")
171 .and_then(|value| value.get("base_url"))
172 .and_then(Value::as_str)
173 .or_else(|| catalog.meta.get("base_url").and_then(Value::as_str))
174 .ok_or_else(|| {
175 ProviderError::validation(
176 &catalog.provider.name,
177 &call.action,
178 "missing_openapi_base_url",
179 "OpenAPI provider requires provider.meta.openapi.base_url",
180 )
181 })?;
182 let base = Url::parse(base_url).map_err(|error| {
183 ProviderError::validation(
184 &catalog.provider.name,
185 &call.action,
186 "invalid_openapi_base_url",
187 error.to_string(),
188 )
189 })?;
190 validate_base_url(catalog, &call.action, &base)?;
191
192 let operation_meta = tool.meta.get("openapi");
193 let path = operation_meta
194 .and_then(|value| value.get("path"))
195 .and_then(Value::as_str)
196 .or_else(|| tool.rest.as_ref().and_then(|rest| rest.path.as_deref()))
197 .unwrap_or("");
198 validate_relative_path(catalog, &call.action, path)?;
199
200 let method_str = operation_meta
201 .and_then(|value| value.get("method"))
202 .and_then(Value::as_str)
203 .or_else(|| tool.rest.as_ref().and_then(|rest| rest.method.as_deref()))
204 .unwrap_or("POST")
205 .to_ascii_uppercase();
206 let method = parse_supported_method(catalog, &call.action, &method_str)?;
207
208 Ok(Self {
209 method,
210 base,
211 path: path.to_owned(),
212 })
213 }
214}
215
216fn parse_supported_method(
217 catalog: &ProviderCatalog,
218 action: &str,
219 method: &str,
220) -> Result<reqwest::Method, ProviderError> {
221 match method {
222 "GET" => Ok(reqwest::Method::GET),
223 "POST" => Ok(reqwest::Method::POST),
224 "PUT" => Ok(reqwest::Method::PUT),
225 "PATCH" => Ok(reqwest::Method::PATCH),
226 "DELETE" => Ok(reqwest::Method::DELETE),
227 other => Err(ProviderError::validation(
228 &catalog.provider.name,
229 action,
230 "unsupported_openapi_method",
231 format!("unsupported OpenAPI provider method `{other}`"),
232 )),
233 }
234}
235
236fn validate_base_url(
237 catalog: &ProviderCatalog,
238 action: &str,
239 url: &Url,
240) -> Result<(), ProviderError> {
241 if !matches!(url.scheme(), "http" | "https") {
242 return Err(ProviderError::validation(
243 &catalog.provider.name,
244 action,
245 "openapi_scheme_denied",
246 "OpenAPI provider base_url must use http or https",
247 ));
248 }
249 if url.host_str().is_none() {
250 return Err(ProviderError::validation(
251 &catalog.provider.name,
252 action,
253 "openapi_host_required",
254 "OpenAPI provider base_url must include a host",
255 ));
256 }
257 let host = url.host_str().unwrap_or_default();
265 let network = catalog.capabilities.network.as_ref().ok_or_else(|| {
266 ProviderError::validation(
267 &catalog.provider.name,
268 action,
269 "openapi_network_capability_required",
270 "OpenAPI provider requires an enabled capabilities.network.allowed_hosts grant",
271 )
272 })?;
273 if !network.enabled {
274 return Err(ProviderError::validation(
275 &catalog.provider.name,
276 action,
277 "openapi_network_capability_required",
278 "OpenAPI provider requires an enabled capabilities.network.allowed_hosts grant",
279 ));
280 }
281 if !network.allowed_hosts.iter().any(|allowed| allowed == host) {
282 return Err(ProviderError::validation(
283 &catalog.provider.name,
284 action,
285 "openapi_host_not_allowed",
286 format!("OpenAPI provider host `{host}` is not declared in allowed_hosts"),
287 ));
288 }
289 Ok(())
290}
291
292fn validate_relative_path(
298 catalog: &ProviderCatalog,
299 action: &str,
300 path: &str,
301) -> Result<(), ProviderError> {
302 let lower = path.to_ascii_lowercase();
309 if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("//") {
310 return Err(ProviderError::validation(
311 &catalog.provider.name,
312 action,
313 "openapi_absolute_operation_url_denied",
314 "OpenAPI provider operation paths must be relative to the pinned base_url",
315 ));
316 }
317 Ok(())
318}
319
320fn map_openapi_error(
321 provider: &str,
322 action: &str,
323 error: soma_openapi::OpenApiError,
324) -> ProviderError {
325 use soma_openapi::OpenApiError as E;
326 match &error {
327 E::InvalidPathParam { param, .. } => ProviderError::validation(
328 provider,
329 action,
330 "openapi_invalid_path_param",
331 format!("OpenAPI provider path parameter `{param}` is missing or invalid"),
332 ),
333 E::UpstreamTimeout { .. } => ProviderError::new(
334 "openapi_upstream_timeout",
335 provider,
336 Some(action.to_owned()),
337 "OpenAPI upstream request timed out",
338 "Check the provider endpoint, input, and credentials, then retry.",
339 ),
340 E::RequestBlockedPrivateAddr { .. } => ProviderError::validation(
341 provider,
342 action,
343 "openapi_operation_path_escapes_base",
344 "OpenAPI provider operation path resolved outside the pinned base_url",
345 ),
346 other => ProviderError::new(
347 "openapi_upstream_error",
348 provider,
349 Some(action.to_owned()),
350 format!("OpenAPI upstream request failed: {other}"),
351 "Check the provider endpoint, input, and credentials, then retry.",
352 ),
353 }
354}
355
356#[cfg(test)]
357#[path = "openapi_tests.rs"]
358mod tests;