Skip to main content

volga_open_api/
registry.rs

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