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        let mut op_opt: Option<OpenApiOperation> = None;
124        for doc in docs.values_mut() {
125            if let Some(methods) = doc.paths.get_mut(&spec_path)
126                && let Some(op) = methods.remove(&method_lc)
127            {
128                op_opt = Some(op);
129                if methods.is_empty() {
130                    doc.paths.remove(&spec_path);
131                }
132            }
133        }
134
135        let mut op = op_opt.unwrap_or_default();
136
137        if op.parameters.is_none() && !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_removes_stale_operation_from_all_docs() {
321        let registry = OpenApiRegistry::new(config_with_specs());
322
323        let shared = OpenApiRouteConfig::default().with_docs(["v1", "admin"]);
324        registry.register_route(&Method::GET, "/shared", &shared);
325
326        let target = OpenApiRouteConfig::default().with_doc("v1");
327        registry.rebind_route(&Method::GET, "/shared", &target);
328
329        let v1_doc = registry.document_by_name("v1").expect("v1 document");
330        let admin_doc = registry.document_by_name("admin").expect("admin document");
331
332        assert!(v1_doc.paths["/shared"].contains_key("get"));
333        assert!(!admin_doc.paths.contains_key("/shared"));
334    }
335
336    #[test]
337    fn rebind_route_skips_excluded_paths() {
338        let registry = OpenApiRegistry::new(config_with_specs());
339
340        registry.rebind_route(
341            &Method::GET,
342            "/v1/openapi.json",
343            &OpenApiRouteConfig::default().with_doc("admin"),
344        );
345
346        let admin_doc = registry.document_by_name("admin").expect("admin document");
347        assert!(!admin_doc.paths.contains_key("/v1/openapi.json"));
348    }
349
350    #[test]
351    fn rebind_route_prunes_unreferenced_component_schemas() {
352        let registry =
353            OpenApiRegistry::new(OpenApiConfig::new().with_specs([OpenApiSpec::new("v1")]));
354
355        let first = OpenApiRouteConfig::default().with_response_schema(
356            200u16,
357            crate::schema::OpenApiSchema::object()
358                .with_title("User")
359                .with_property("name", crate::schema::OpenApiSchema::string()),
360        );
361        let second = OpenApiRouteConfig::default().with_response_schema(
362            200u16,
363            crate::schema::OpenApiSchema::object()
364                .with_title("User")
365                .with_property("id", crate::schema::OpenApiSchema::integer()),
366        );
367
368        registry.register_route(&Method::GET, "/users", &first);
369        registry.apply_route_config(&Method::GET, "/users", &first);
370
371        registry.rebind_route(&Method::GET, "/users", &second);
372
373        let v1_doc = registry.document_by_name("v1").expect("v1 document");
374
375        assert_eq!(v1_doc.components.schemas.len(), 1);
376        assert!(!v1_doc.components.schemas.contains_key("User"));
377        assert!(v1_doc.components.schemas.contains_key("User_2"));
378    }
379
380    #[test]
381    fn it_tests_normalization_for_excluded_paths() {
382        assert_eq!(
383            normalize_path_for_compare("/v1/openapi.json").as_str(),
384            "/v1/openapi.json"
385        );
386        assert_eq!(normalize_path_for_compare("openapi"), "/openapi");
387        assert_eq!(normalize_path_for_compare(""), "/");
388        assert_eq!(normalize_path_for_compare("/openapi/"), "/openapi");
389    }
390
391    #[test]
392    #[should_panic(expected = "OpenAPI spec names must be unique")]
393    fn registry_new_panics_on_duplicate_spec_names() {
394        let config = OpenApiConfig::new().with_specs([
395            OpenApiSpec::new("v1"),
396            OpenApiSpec::new("v1").with_path("/v1-alt/openapi.json"),
397        ]);
398
399        let _ = OpenApiRegistry::new(config);
400    }
401
402    #[test]
403    #[should_panic(expected = "OpenAPI spec paths must be unique after normalization")]
404    fn registry_new_panics_on_duplicate_normalized_spec_paths() {
405        let config = OpenApiConfig::new().with_specs([
406            OpenApiSpec::new("v1").with_path("docs/openapi.json"),
407            OpenApiSpec::new("admin").with_path("/docs/openapi.json/"),
408        ]);
409
410        let _ = OpenApiRegistry::new(config);
411    }
412}