Skip to main content

volga_open_api/
registry.rs

1//! Types and utils for the OpenAPI registry.
2
3use 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/// OpenAPI runtime registry.
17#[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    /// Creates a new [`OpenApiRegistry`]
27    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    /// Registers a route in the OpenAPI registry.
66    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    /// Rebinds route to another spec.
100    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 user specified docs but none exist -> do not drop the route from all specs.
118        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    /// Applies route configuration
165    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    /// Returns OpenAPI document by spec name.
202    pub fn document_by_name(&self, name: &str) -> Option<OpenApiDocument> {
203        self.lock().get(name).cloned()
204    }
205
206    /// Returns a list of defined specs
207    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    // treat "" as "/"
262    let p = if p.is_empty() { 
263        "/" 
264    } else { 
265        p
266    };
267
268    // ensure leading slash
269    let p = if p.starts_with('/') {
270        p.to_string()
271    } else {
272        format!("/{p}")
273    };
274
275    // drop trailing slash (except root)
276    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}