Skip to main content

soma_openapi/
registry.rs

1use std::collections::HashMap;
2use std::io::ErrorKind;
3use std::sync::Arc;
4use std::time::Duration;
5
6use tokio::io::AsyncReadExt;
7
8use crate::config::{OpenApiConfig, OpenApiCredential, OpenApiSpecConfig, SpecSource};
9use crate::error::OpenApiError;
10
11pub const MAX_SPECS: usize = 10;
12pub const MAX_OPERATIONS_PER_SPEC: usize = 200;
13pub const MAX_SPEC_BYTES: usize = 4 * 1024 * 1024;
14
15#[derive(Debug, Clone)]
16pub struct OperationHandle {
17    pub operation_id: String,
18    pub method: reqwest::Method,
19    pub path_template: String,
20    pub base_url: url::Url,
21    pub credential: Option<OpenApiCredential>,
22}
23
24#[derive(Debug, Clone)]
25pub struct SpecEntry {
26    pub operations: HashMap<String, OperationHandle>,
27}
28
29#[derive(Clone, Default)]
30pub struct OpenApiRegistry {
31    inner: Arc<HashMap<String, SpecEntry>>,
32}
33
34impl OpenApiRegistry {
35    pub async fn from_config(cfg: OpenApiConfig, per_spec_timeout: Duration) -> Self {
36        Self::load(cfg, per_spec_timeout).await
37    }
38
39    pub async fn load(cfg: OpenApiConfig, per_spec_timeout: Duration) -> Self {
40        let total = cfg.specs.len();
41        let specs: Vec<_> = cfg.specs.into_iter().take(MAX_SPECS).collect();
42        if total > MAX_SPECS {
43            tracing::warn!(
44                service = "openapi",
45                kept = MAX_SPECS,
46                configured = total,
47                "openapi: MAX_SPECS exceeded, extra specs dropped"
48            );
49        }
50
51        let loads = specs.into_iter().map(|spec| async move {
52            let label = spec.label.clone();
53            match tokio::time::timeout(per_spec_timeout, load_one_spec(spec)).await {
54                Ok(Ok(entry)) => Some((label, entry)),
55                Ok(Err(error)) => {
56                    tracing::warn!(
57                        service = "openapi",
58                        label = %label,
59                        kind = error.kind(),
60                        "openapi spec omitted: load failed"
61                    );
62                    None
63                }
64                Err(_) => {
65                    tracing::warn!(
66                        service = "openapi",
67                        label = %label,
68                        kind = "timeout",
69                        "openapi spec omitted: load timed out"
70                    );
71                    None
72                }
73            }
74        });
75
76        let map = futures::future::join_all(loads)
77            .await
78            .into_iter()
79            .flatten()
80            .collect();
81        Self {
82            inner: Arc::new(map),
83        }
84    }
85
86    #[cfg(test)]
87    #[must_use]
88    pub fn from_map_for_test(map: HashMap<String, SpecEntry>) -> Self {
89        Self {
90            inner: Arc::new(map),
91        }
92    }
93
94    #[must_use]
95    pub fn labels(&self) -> Vec<String> {
96        let mut labels: Vec<_> = self.inner.keys().cloned().collect();
97        labels.sort();
98        labels
99    }
100
101    #[must_use]
102    pub fn is_empty(&self) -> bool {
103        self.inner.is_empty()
104    }
105
106    pub fn operation(&self, label: &str, op: &str) -> Result<&OperationHandle, OpenApiError> {
107        let entry = self
108            .inner
109            .get(label)
110            .ok_or_else(|| OpenApiError::UnknownInstance {
111                label: label.to_string(),
112                valid: self.labels(),
113            })?;
114        entry
115            .operations
116            .get(op)
117            .ok_or_else(|| OpenApiError::UnknownOperation {
118                label: label.to_string(),
119                operation_id: op.to_string(),
120            })
121    }
122}
123
124async fn load_one_spec(spec: OpenApiSpecConfig) -> Result<SpecEntry, OpenApiError> {
125    if spec.is_reserved_label() {
126        return Err(OpenApiError::SpecParse { label: spec.label });
127    }
128    let base_url = crate::ssrf::validate_base_url(&spec)?;
129    let spec_json = fetch_spec_json(&spec.spec_source, &spec.label).await?;
130    let descriptors =
131        crate::convert::convert_spec(&spec.label, &spec_json, &spec.allowed_operations)?;
132    let converted = descriptors.len();
133    let mut operations = HashMap::new();
134
135    for descriptor in descriptors.into_iter().take(MAX_OPERATIONS_PER_SPEC) {
136        operations.insert(
137            descriptor.operation_id.clone(),
138            OperationHandle {
139                operation_id: descriptor.operation_id,
140                method: descriptor.method,
141                path_template: descriptor.path_template,
142                base_url: base_url.clone(),
143                credential: spec.credential.clone(),
144            },
145        );
146    }
147
148    if converted > MAX_OPERATIONS_PER_SPEC {
149        tracing::warn!(
150            service = "openapi",
151            label = %spec.label,
152            kept = MAX_OPERATIONS_PER_SPEC,
153            converted,
154            "openapi: MAX_OPERATIONS_PER_SPEC exceeded, extra operations dropped"
155        );
156    }
157    if operations.is_empty() {
158        tracing::warn!(
159            service = "openapi",
160            label = %spec.label,
161            allowed = spec.allowed_operations.len(),
162            kind = "empty_allowlist",
163            "openapi spec loaded but no operations matched the allowlist"
164        );
165    }
166
167    Ok(SpecEntry { operations })
168}
169
170async fn fetch_spec_json(source: &SpecSource, label: &str) -> Result<String, OpenApiError> {
171    match source {
172        SpecSource::Url(url) => {
173            crate::ssrf::validate_spec_url(label, url)?;
174            crate::http::fetch_url_capped(url, MAX_SPEC_BYTES, label).await
175        }
176        SpecSource::Path(path) => read_path_capped(path, MAX_SPEC_BYTES, label).await,
177    }
178}
179
180async fn read_path_capped(
181    path: &std::path::Path,
182    cap: usize,
183    label: &str,
184) -> Result<String, OpenApiError> {
185    if let Ok(metadata) = tokio::fs::metadata(path).await {
186        if metadata.len() > cap as u64 {
187            return Err(OpenApiError::SpecTooLarge {
188                label: label.to_string(),
189            });
190        }
191    }
192
193    let file = tokio::fs::File::open(path)
194        .await
195        .map_err(|_| OpenApiError::SpecParse {
196            label: label.to_string(),
197        })?;
198    let mut reader = file.take(cap as u64 + 1);
199    let mut bytes = Vec::new();
200    reader
201        .read_to_end(&mut bytes)
202        .await
203        .map_err(|error| match error.kind() {
204            ErrorKind::InvalidData => OpenApiError::SpecParse {
205                label: label.to_string(),
206            },
207            _ => OpenApiError::SpecParse {
208                label: label.to_string(),
209            },
210        })?;
211
212    if bytes.len() > cap {
213        return Err(OpenApiError::SpecTooLarge {
214            label: label.to_string(),
215        });
216    }
217
218    String::from_utf8(bytes).map_err(|_| OpenApiError::SpecParse {
219        label: label.to_string(),
220    })
221}