Skip to main content

soma_openapi/
http.rs

1pub(crate) mod body;
2pub(crate) mod client;
3pub(crate) mod params;
4pub(crate) mod resolve;
5
6#[cfg(test)]
7mod body_tests;
8#[cfg(test)]
9mod client_tests;
10#[cfg(test)]
11mod params_tests;
12#[cfg(test)]
13mod resolve_tests;
14
15use crate::error::OpenApiError;
16use crate::registry::OperationHandle;
17
18pub const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
19
20pub use client::build_dispatch_client;
21
22pub async fn fetch_url_capped(
23    url: &url::Url,
24    cap: usize,
25    label: &str,
26) -> Result<String, OpenApiError> {
27    let (pinned_client, pinned_ip) = resolve::pinned_client_for(url, label).await?;
28    let response = pinned_client
29        .get(url.clone())
30        .send()
31        .await
32        .map_err(|error| map_send_error(error, label))?;
33    resolve::recheck_peer(&response, pinned_ip, label)?;
34    if !response.status().is_success() {
35        return Err(OpenApiError::SpecParse {
36            label: label.to_string(),
37        });
38    }
39    body::collect_spec_capped(response, cap, label).await
40}
41
42/// Which trust boundary `execute_operation_inner` should enforce for a given
43/// call. Kept as an enum rather than two independent booleans (`enforce_ssrf`,
44/// `lenient_body`) so illegal combinations — like enforcing DNS-pinned SSRF
45/// protection while also being lenient about non-JSON bodies, a combination
46/// nothing in this crate has ever needed or tested — are unrepresentable.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48enum DispatchTrust {
49    /// Registry/spec-driven dispatch against arbitrary, potentially
50    /// public-internet hosts: enforce the DNS-pinned SSRF guard and require a
51    /// valid JSON success body.
52    Pinned,
53    /// The caller has already restricted the target host through its own
54    /// allowlist (e.g. a drop-in provider manifest's declared
55    /// `capabilities.network.allowed_hosts`): skip the SSRF guard and
56    /// tolerate a non-JSON success body.
57    AllowlistedHost,
58    /// Test-only: skip the SSRF guard but keep the strict JSON-body
59    /// requirement, isolating SSRF-guard behavior from body-parsing
60    /// behavior in unit tests.
61    #[cfg(test)]
62    UnpinnedStrictBody,
63}
64
65impl DispatchTrust {
66    fn enforce_ssrf(self) -> bool {
67        matches!(self, Self::Pinned)
68    }
69
70    fn lenient_body(self) -> bool {
71        matches!(self, Self::AllowlistedHost)
72    }
73}
74
75pub async fn execute_operation(
76    client: &reqwest::Client,
77    op: &OperationHandle,
78    params: serde_json::Value,
79) -> Result<serde_json::Value, OpenApiError> {
80    execute_operation_inner(client, op, params, DispatchTrust::Pinned).await
81}
82
83/// Dispatches an operation while skipping this crate's DNS-pinned SSRF guard
84/// and tolerating a non-JSON success body (wrapped as `{ "text": <body> }`
85/// instead of erroring).
86///
87/// This exists solely for `soma-provider-adapters::openapi`'s manifest-driven
88/// OpenAPI provider. That adapter's trust model is an operator-declared
89/// `capabilities.network.allowed_hosts` allowlist rather than public-internet
90/// DNS pinning, and the allowlist may legitimately include loopback/private
91/// hosts (e.g. a local sidecar) — see that crate's `openapi` module docs.
92/// Callers MUST have already restricted `op.base_url`'s host through an
93/// equivalent explicit allowlist before calling this. Registry/spec-driven
94/// dispatch (`dispatch_openapi_call`) must keep going through
95/// `execute_operation`, never this function.
96pub async fn execute_operation_for_allowlisted_host(
97    client: &reqwest::Client,
98    op: &OperationHandle,
99    params: serde_json::Value,
100) -> Result<serde_json::Value, OpenApiError> {
101    execute_operation_inner(client, op, params, DispatchTrust::AllowlistedHost).await
102}
103
104#[cfg(test)]
105pub(crate) async fn execute_operation_no_ssrf(
106    client: &reqwest::Client,
107    op: &OperationHandle,
108    params: serde_json::Value,
109) -> Result<serde_json::Value, OpenApiError> {
110    execute_operation_inner(client, op, params, DispatchTrust::UnpinnedStrictBody).await
111}
112
113async fn execute_operation_inner(
114    client: &reqwest::Client,
115    op: &OperationHandle,
116    params: serde_json::Value,
117    trust: DispatchTrust,
118) -> Result<serde_json::Value, OpenApiError> {
119    let (used_path_params, url) = params::build_url_with_params(op, &params)?;
120    let (send_client, pinned_ip) = if trust.enforce_ssrf() {
121        let (client, ip) = resolve::pinned_client_for(&url, &op.operation_id).await?;
122        (client, Some(ip))
123    } else {
124        (client.clone(), None)
125    };
126
127    let url = params::apply_query(url, op, &params, &used_path_params);
128    let mut request = send_client.request(op.method.clone(), url);
129    request = params::inject_credential(request, op);
130    request = params::apply_body(request, op, &params, &used_path_params);
131
132    let response = request
133        .send()
134        .await
135        .map_err(|error| map_send_error(error, &op.operation_id))?;
136    if let Some(pinned_ip) = pinned_ip {
137        resolve::recheck_peer(&response, pinned_ip, &op.operation_id)?;
138    }
139
140    if !response.status().is_success() {
141        return Err(OpenApiError::UpstreamRequest {
142            label: op.operation_id.clone(),
143        });
144    }
145    let body =
146        body::collect_response_capped(response, MAX_RESPONSE_BYTES, &op.operation_id).await?;
147    if trust.lenient_body() {
148        let parsed = serde_json::from_str::<serde_json::Value>(&body)
149            .unwrap_or_else(|_| serde_json::json!({ "text": body }));
150        return Ok(parsed);
151    }
152    if body.trim().is_empty() {
153        return Ok(serde_json::Value::Null);
154    }
155    serde_json::from_str(&body).map_err(|_| OpenApiError::UpstreamRequest {
156        label: op.operation_id.clone(),
157    })
158}
159
160pub(crate) fn map_send_error(error: reqwest::Error, label: &str) -> OpenApiError {
161    if error.is_timeout() {
162        OpenApiError::UpstreamTimeout {
163            label: label.to_string(),
164        }
165    } else {
166        OpenApiError::UpstreamRequest {
167            label: label.to_string(),
168        }
169    }
170}