1use std::{collections::{BTreeMap, HashSet}, sync::{Arc, Mutex}};
4use http::Method;
5
6use super::{
7 doc::{OpenApiDocument, OpenApiComponents, OpenApiInfo},
8 config::{OpenApiConfig, OpenApiSpec},
9 route::OpenApiRouteConfig,
10 op::OpenApiOperation,
11 param::normalize_openapi_path
12};
13
14const DEFAULT_OPENAPI_VERSION: &str = "3.0.0";
15
16#[derive(Clone, Debug)]
18pub struct OpenApiRegistry {
19 inner: Arc<Mutex<BTreeMap<String, OpenApiDocument>>>,
20 specs: Vec<OpenApiSpec>,
21 ui_path: String,
22 ui_enabled: bool,
23}
24
25impl OpenApiRegistry {
26 pub fn new(config: OpenApiConfig) -> Self {
28 validate_specs(&config.specs);
29
30 let base_doc =
31 |title: String, version: String, description: Option<String>| OpenApiDocument {
32 openapi: DEFAULT_OPENAPI_VERSION.to_string(),
33 info: OpenApiInfo {
34 title,
35 version,
36 description,
37 },
38 paths: BTreeMap::new(),
39 components: OpenApiComponents {
40 schemas: BTreeMap::new(),
41 },
42 };
43
44
45 let mut docs = BTreeMap::new();
46 for s in &config.specs {
47 docs.insert(
48 s.name.clone(),
49 base_doc(
50 config.title.clone(),
51 config.version.clone(),
52 config.description.clone()
53 ),
54 );
55 }
56
57 Self {
58 inner: Arc::new(Mutex::new(docs)),
59 specs: config.specs,
60 ui_path: config.ui_path,
61 ui_enabled: config.ui_enabled,
62 }
63 }
64
65 pub fn register_route(
67 &self,
68 method: &Method,
69 path: &str,
70 cfg: &OpenApiRouteConfig
71 ) {
72 if self.is_excluded_path(path) {
73 return;
74 }
75
76 let (spec_path, path_params) = normalize_openapi_path(path);
77
78 let mut docs = self.lock();
79 let method = method.as_str().to_ascii_lowercase();
80 let targets = self.target_doc_names(cfg);
81
82 for doc_name in targets {
83 if let Some(doc) = docs.get_mut(doc_name) {
84 let entry = doc.paths
85 .entry(spec_path.clone())
86 .or_default();
87
88 let op = entry
89 .entry(method.clone())
90 .or_insert_with(OpenApiOperation::default);
91
92 if op.parameters.is_none() && !path_params.is_empty() {
93 op.parameters = Some(path_params.clone());
94 }
95 }
96 }
97 }
98
99 pub fn rebind_route(&self, method: &Method, path: &str, cfg: &OpenApiRouteConfig) {
101 if self.is_excluded_path(path) {
102 return;
103 }
104
105 let (spec_path, path_params) = normalize_openapi_path(path);
106
107 let method_lc = method.as_str().to_ascii_lowercase();
108 let targets = self.target_doc_names(cfg);
109
110 let mut docs = self.lock();
111
112 let mut valid_targets: Vec<&str> = targets
113 .into_iter()
114 .filter(|name| docs.contains_key(*name))
115 .collect();
116
117 if valid_targets.is_empty() {
119 #[cfg(debug_assertions)]
120 {
121 eprintln!(
122 "OpenAPI: rebind_route ignored: no such spec(s) for route {method_lc} {spec_path}"
123 );
124 }
125 return;
126 }
127
128 let mut op_opt: Option<OpenApiOperation> = None;
129 for doc in docs.values_mut() {
130 if let Some(methods) = doc.paths.get_mut(&spec_path)
131 && let Some(op) = methods.remove(&method_lc)
132 {
133 op_opt = Some(op);
134 if methods.is_empty() {
135 doc.paths.remove(&spec_path);
136 }
137 }
138 }
139
140 let mut op = op_opt.unwrap_or_default();
141
142 if op.parameters.is_none() && !path_params.is_empty() {
143 op.parameters = Some(path_params.clone());
144 }
145
146 for name in valid_targets.drain(..) {
147 let Some(doc) = docs.get_mut(name) else {
148 continue;
149 };
150
151 cfg.apply_to_operation(&mut op, &mut doc.components.schemas);
152
153 doc.paths
154 .entry(spec_path.clone())
155 .or_default()
156 .insert(method_lc.clone(), op.clone());
157 }
158
159 for doc in docs.values_mut() {
160 doc.prune_unreferenced_components();
161 }
162 }
163
164 pub fn apply_route_config(
166 &self,
167 method: &Method,
168 path: &str,
169 cfg: &OpenApiRouteConfig,
170 ) {
171 if self.is_excluded_path(path) {
172 return;
173 }
174
175 let (spec_path, path_params) = normalize_openapi_path(path);
176
177 let mut docs = self.lock();
178 let method_lc = method.as_str().to_ascii_lowercase();
179 let targets = self.target_doc_names(cfg);
180
181 for doc_name in targets {
182 let Some(doc) = docs.get_mut(doc_name) else {
183 continue;
184 };
185
186 let OpenApiDocument { paths, components, .. } = doc;
187
188 let entry = paths.entry(spec_path.clone()).or_default();
189 let op = entry
190 .entry(method_lc.clone())
191 .or_insert_with(OpenApiOperation::default);
192
193 if op.parameters.is_none() && !path_params.is_empty() {
194 op.parameters = Some(path_params.clone());
195 }
196
197 cfg.apply_to_operation(op, &mut components.schemas);
198 }
199 }
200
201 pub fn document_by_name(&self, name: &str) -> Option<OpenApiDocument> {
203 self.lock().get(name).cloned()
204 }
205
206 pub fn specs(&self) -> &[OpenApiSpec] {
208 &self.specs
209 }
210
211 #[inline]
212 fn is_excluded_path(&self, path: &str) -> bool {
213 let p = normalize_path_for_compare(path);
214
215 if self.ui_enabled && p == normalize_path_for_compare(&self.ui_path) {
216 return true;
217 }
218
219 self.specs
220 .iter()
221 .any(|s| p == normalize_path_for_compare(&s.spec_path))
222 }
223
224 fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, OpenApiDocument>> {
225 self.inner.lock().expect("openapi registry lock poisoned")
226 }
227
228 fn target_doc_names<'a>(&'a self, cfg: &'a OpenApiRouteConfig) -> Vec<&'a str> {
229 if let Some(docs) = cfg.docs() {
230 docs.iter().map(|s| s.as_str()).collect()
231 } else {
232 self.specs
233 .first()
234 .map(|s| vec![s.name.as_str()])
235 .unwrap_or_default()
236 }
237 }
238}
239
240fn validate_specs(specs: &[OpenApiSpec]) {
241 let mut names = HashSet::with_capacity(specs.len());
242 let mut paths = HashSet::with_capacity(specs.len());
243
244 for spec in specs {
245 assert!(
246 names.insert(spec.name.clone()),
247 "OpenAPI spec names must be unique; duplicate name: {}",
248 spec.name
249 );
250
251 let normalized_path = normalize_path_for_compare(&spec.spec_path);
252 assert!(
253 paths.insert(normalized_path.clone()),
254 "OpenAPI spec paths must be unique after normalization; duplicate path: {normalized_path}"
255 );
256 }
257}
258
259#[inline]
260fn normalize_path_for_compare(p: &str) -> String {
261 let p = if p.is_empty() {
263 "/"
264 } else {
265 p
266 };
267
268 let p = if p.starts_with('/') {
270 p.to_string()
271 } else {
272 format!("/{p}")
273 };
274
275 if p.len() > 1 && p.ends_with('/') {
277 p.trim_end_matches('/').to_string()
278 } else {
279 p
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn config_with_specs() -> OpenApiConfig {
288 OpenApiConfig::new()
289 .with_specs([OpenApiSpec::new("v1"), OpenApiSpec::new("admin")])
290 .with_ui()
291 .with_ui_path("/openapi")
292 }
293
294 #[test]
295 fn register_route_skips_ui_and_spec_paths() {
296 let registry = OpenApiRegistry::new(config_with_specs());
297
298 registry.register_route(&Method::GET, "/openapi", &OpenApiRouteConfig::default());
299 registry.register_route(
300 &Method::GET,
301 "v1/openapi.json",
302 &OpenApiRouteConfig::default(),
303 );
304 registry.register_route(&Method::GET, "/users", &OpenApiRouteConfig::default());
305
306 let v1_doc = registry.document_by_name("v1").expect("v1 document");
307 assert!(v1_doc.paths.contains_key("/users"));
308 assert!(!v1_doc.paths.contains_key("/openapi"));
309 assert!(!v1_doc.paths.contains_key("/sv1/openapi.json"));
310 }
311
312 #[test]
313 fn rebind_route_moves_operation_to_target_doc() {
314 let registry = OpenApiRegistry::new(config_with_specs());
315
316 registry.register_route(&Method::POST, "/pets", &OpenApiRouteConfig::default());
317 registry.rebind_route(
318 &Method::POST,
319 "/pets",
320 &OpenApiRouteConfig::default().with_doc("admin"),
321 );
322
323 let v1_doc = registry.document_by_name("v1").expect("v1 document");
324 let admin_doc = registry.document_by_name("admin").expect("admin document");
325
326 assert!(!v1_doc.paths.contains_key("/pets"));
327 assert!(admin_doc.paths.contains_key("/pets"));
328 assert!(admin_doc.paths["/pets"].contains_key("post"));
329 }
330
331 #[test]
332 fn rebind_route_removes_stale_operation_from_all_docs() {
333 let registry = OpenApiRegistry::new(config_with_specs());
334
335 let shared = OpenApiRouteConfig::default().with_docs(["v1", "admin"]);
336 registry.register_route(&Method::GET, "/shared", &shared);
337
338 let target = OpenApiRouteConfig::default().with_doc("v1");
339 registry.rebind_route(&Method::GET, "/shared", &target);
340
341 let v1_doc = registry.document_by_name("v1").expect("v1 document");
342 let admin_doc = registry.document_by_name("admin").expect("admin document");
343
344 assert!(v1_doc.paths["/shared"].contains_key("get"));
345 assert!(!admin_doc.paths.contains_key("/shared"));
346 }
347
348 #[test]
349 fn rebind_route_skips_excluded_paths() {
350 let registry = OpenApiRegistry::new(config_with_specs());
351
352 registry.rebind_route(
353 &Method::GET,
354 "/v1/openapi.json",
355 &OpenApiRouteConfig::default().with_doc("admin"),
356 );
357
358 let admin_doc = registry.document_by_name("admin").expect("admin document");
359 assert!(!admin_doc.paths.contains_key("/v1/openapi.json"));
360 }
361
362 #[test]
363 fn rebind_route_prunes_unreferenced_component_schemas() {
364 let registry = OpenApiRegistry::new(OpenApiConfig::new().with_specs([OpenApiSpec::new("v1")]));
365
366 let first = OpenApiRouteConfig::default().with_response_schema(
367 crate::schema::OpenApiSchema::object()
368 .with_title("User")
369 .with_property("name", crate::schema::OpenApiSchema::string()),
370 );
371 let second = OpenApiRouteConfig::default().with_response_schema(
372 crate::schema::OpenApiSchema::object()
373 .with_title("User")
374 .with_property("id", crate::schema::OpenApiSchema::integer()),
375 );
376
377 registry.register_route(&Method::GET, "/users", &first);
378 registry.apply_route_config(&Method::GET, "/users", &first);
379
380 registry.rebind_route(&Method::GET, "/users", &second);
381
382 let v1_doc = registry.document_by_name("v1").expect("v1 document");
383
384 assert_eq!(v1_doc.components.schemas.len(), 1);
385 assert!(!v1_doc.components.schemas.contains_key("User"));
386 assert!(v1_doc.components.schemas.contains_key("User_2"));
387 }
388
389 #[test]
390 fn it_tests_normalization_for_excluded_paths() {
391 assert_eq!(normalize_path_for_compare("/v1/openapi.json").as_str(), "/v1/openapi.json");
392 assert_eq!(normalize_path_for_compare("openapi"), "/openapi");
393 assert_eq!(normalize_path_for_compare(""), "/");
394 assert_eq!(normalize_path_for_compare("/openapi/"), "/openapi");
395 }
396
397 #[test]
398 #[should_panic(expected = "OpenAPI spec names must be unique")]
399 fn registry_new_panics_on_duplicate_spec_names() {
400 let config = OpenApiConfig::new().with_specs([
401 OpenApiSpec::new("v1"),
402 OpenApiSpec::new("v1").with_path("/v1-alt/openapi.json"),
403 ]);
404
405 let _ = OpenApiRegistry::new(config);
406 }
407
408 #[test]
409 #[should_panic(expected = "OpenAPI spec paths must be unique after normalization")]
410 fn registry_new_panics_on_duplicate_normalized_spec_paths() {
411 let config = OpenApiConfig::new().with_specs([
412 OpenApiSpec::new("v1").with_path("docs/openapi.json"),
413 OpenApiSpec::new("admin").with_path("/docs/openapi.json/"),
414 ]);
415
416 let _ = OpenApiRegistry::new(config);
417 }
418}