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}