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        self.register_in(method, path, &self.docs_for(cfg));
70    }
71
72    /// Registers a route in `docs` alone and applies its configuration there.
73    ///
74    /// `docs` is meant to be some of the documents `cfg` names - see [`Self::docs_for`] - for a
75    /// route that has to be left out of the rest. A name no document carries is skipped.
76    pub fn describe_route_in(
77        &self,
78        method: &Method,
79        path: &str,
80        cfg: &OpenApiRouteConfig,
81        docs: &[&str],
82    ) {
83        self.register_in(method, path, docs);
84        self.apply_in(method, path, cfg, docs);
85    }
86
87    /// Returns the names of the documents a route configured with `cfg` is described in: the
88    /// ones it is bound to, or the first spec when it is bound to none.
89    pub fn docs_for<'a>(&'a self, cfg: &'a OpenApiRouteConfig) -> Vec<&'a str> {
90        if let Some(docs) = cfg.docs() {
91            docs.iter().map(|s| s.as_str()).collect()
92        } else {
93            self.specs
94                .first()
95                .map(|s| vec![s.name.as_str()])
96                .unwrap_or_default()
97        }
98    }
99
100    fn register_in(&self, method: &Method, path: &str, targets: &[&str]) {
101        if self.is_excluded_path(path) {
102            return;
103        }
104
105        let (spec_path, path_params) = normalize_openapi_path(path);
106
107        let mut docs = self.lock();
108        let method = method.as_str().to_ascii_lowercase();
109
110        for doc_name in targets {
111            if let Some(doc) = docs.get_mut(*doc_name) {
112                let entry = doc.paths.entry(spec_path.clone()).or_default();
113
114                let op = entry
115                    .entry(method.clone())
116                    .or_insert_with(OpenApiOperation::default);
117
118                if op.parameters.is_none() && !path_params.is_empty() {
119                    op.parameters = Some(path_params.clone());
120                }
121            }
122        }
123    }
124
125    /// Rebinds route to another spec.
126    pub fn rebind_route(&self, method: &Method, path: &str, cfg: &OpenApiRouteConfig) {
127        if self.is_excluded_path(path) {
128            return;
129        }
130
131        let (spec_path, path_params) = normalize_openapi_path(path);
132
133        let method_lc = method.as_str().to_ascii_lowercase();
134        let targets = self.docs_for(cfg);
135
136        let mut docs = self.lock();
137
138        let mut valid_targets: Vec<&str> = targets
139            .into_iter()
140            .filter(|name| docs.contains_key(*name))
141            .collect();
142
143        // If user specified docs but none exist -> do not drop the route from all specs.
144        if valid_targets.is_empty() {
145            #[cfg(debug_assertions)]
146            {
147                eprintln!(
148                    "OpenAPI: rebind_route ignored: no such spec(s) for route {method_lc} {spec_path}"
149                );
150            }
151            return;
152        }
153
154        for doc in docs.values_mut() {
155            remove_operation(doc, &spec_path, &method_lc);
156        }
157
158        // Built from `cfg` alone rather than from the operation that was there: `cfg`
159        // describes the route as it stands, and a field it no longer carries - a summary
160        // written for a handler that has since been replaced - is not part of it
161        let mut op = OpenApiOperation::default();
162
163        if !path_params.is_empty() {
164            op.parameters = Some(path_params.clone());
165        }
166
167        for name in valid_targets.drain(..) {
168            let Some(doc) = docs.get_mut(name) else {
169                continue;
170            };
171
172            cfg.apply_to_operation(&mut op, &mut doc.components.schemas);
173
174            doc.paths
175                .entry(spec_path.clone())
176                .or_default()
177                .insert(method_lc.clone(), op.clone());
178        }
179
180        for doc in docs.values_mut() {
181            doc.prune_unreferenced_components();
182        }
183    }
184
185    /// Removes a route's operation from every spec.
186    ///
187    /// A route that is not in a spec is left out of it, so removing one that was never
188    /// registered does nothing.
189    pub fn remove_route(&self, method: &Method, path: &str) {
190        if self.is_excluded_path(path) {
191            return;
192        }
193
194        let (spec_path, _) = normalize_openapi_path(path);
195        let method_lc = method.as_str().to_ascii_lowercase();
196
197        let mut docs = self.lock();
198        for doc in docs.values_mut() {
199            remove_operation(doc, &spec_path, &method_lc);
200            doc.prune_unreferenced_components();
201        }
202    }
203
204    /// Applies route configuration
205    pub fn apply_route_config(&self, method: &Method, path: &str, cfg: &OpenApiRouteConfig) {
206        self.apply_in(method, path, cfg, &self.docs_for(cfg));
207    }
208
209    fn apply_in(&self, method: &Method, path: &str, cfg: &OpenApiRouteConfig, targets: &[&str]) {
210        if self.is_excluded_path(path) {
211            return;
212        }
213
214        let (spec_path, path_params) = normalize_openapi_path(path);
215
216        let mut docs = self.lock();
217        let method_lc = method.as_str().to_ascii_lowercase();
218
219        for doc_name in targets {
220            let Some(doc) = docs.get_mut(*doc_name) else {
221                continue;
222            };
223
224            let OpenApiDocument {
225                paths, components, ..
226            } = doc;
227
228            let entry = paths.entry(spec_path.clone()).or_default();
229            let op = entry
230                .entry(method_lc.clone())
231                .or_insert_with(OpenApiOperation::default);
232
233            if op.parameters.is_none() && !path_params.is_empty() {
234                op.parameters = Some(path_params.clone());
235            }
236
237            cfg.apply_to_operation(op, &mut components.schemas);
238        }
239    }
240
241    /// Returns OpenAPI document by spec name.
242    pub fn document_by_name(&self, name: &str) -> Option<OpenApiDocument> {
243        self.lock().get(name).cloned()
244    }
245
246    /// Returns a list of defined specs
247    pub fn specs(&self) -> &[OpenApiSpec] {
248        &self.specs
249    }
250
251    #[inline]
252    fn is_excluded_path(&self, path: &str) -> bool {
253        let p = normalize_path_for_compare(path);
254
255        if self.ui_enabled && p == normalize_path_for_compare(&self.ui_path) {
256            return true;
257        }
258
259        self.specs
260            .iter()
261            .any(|s| p == normalize_path_for_compare(&s.spec_path))
262    }
263
264    fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, OpenApiDocument>> {
265        self.inner.lock().expect("openapi registry lock poisoned")
266    }
267}
268
269/// Removes the operation `method` has at `spec_path`, and the path with it once no method is
270/// left there
271#[inline]
272fn remove_operation(doc: &mut OpenApiDocument, spec_path: &str, method: &str) {
273    if let Some(methods) = doc.paths.get_mut(spec_path)
274        && methods.remove(method).is_some()
275        && methods.is_empty()
276    {
277        doc.paths.remove(spec_path);
278    }
279}
280
281fn validate_specs(specs: &[OpenApiSpec]) {
282    let mut names = HashSet::with_capacity(specs.len());
283    let mut paths = HashSet::with_capacity(specs.len());
284
285    for spec in specs {
286        assert!(
287            names.insert(spec.name.clone()),
288            "OpenAPI spec names must be unique; duplicate name: {}",
289            spec.name
290        );
291
292        let normalized_path = normalize_path_for_compare(&spec.spec_path);
293        assert!(
294            paths.insert(normalized_path.clone()),
295            "OpenAPI spec paths must be unique after normalization; duplicate path: {normalized_path}"
296        );
297    }
298}
299
300#[inline]
301fn normalize_path_for_compare(p: &str) -> String {
302    // treat "" as "/"
303    let p = if p.is_empty() { "/" } else { p };
304
305    // ensure leading slash
306    let p = if p.starts_with('/') {
307        p.to_string()
308    } else {
309        format!("/{p}")
310    };
311
312    // drop trailing slash (except root)
313    if p.len() > 1 && p.ends_with('/') {
314        p.trim_end_matches('/').to_string()
315    } else {
316        p
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    fn config_with_specs() -> OpenApiConfig {
325        OpenApiConfig::new()
326            .with_specs([OpenApiSpec::new("v1"), OpenApiSpec::new("admin")])
327            .with_ui()
328            .with_ui_path("/openapi")
329    }
330
331    #[test]
332    fn register_route_skips_ui_and_spec_paths() {
333        let registry = OpenApiRegistry::new(config_with_specs());
334
335        registry.register_route(&Method::GET, "/openapi", &OpenApiRouteConfig::default());
336        registry.register_route(
337            &Method::GET,
338            "v1/openapi.json",
339            &OpenApiRouteConfig::default(),
340        );
341        registry.register_route(&Method::GET, "/users", &OpenApiRouteConfig::default());
342
343        let v1_doc = registry.document_by_name("v1").expect("v1 document");
344        assert!(v1_doc.paths.contains_key("/users"));
345        assert!(!v1_doc.paths.contains_key("/openapi"));
346        assert!(!v1_doc.paths.contains_key("/sv1/openapi.json"));
347    }
348
349    #[test]
350    fn remove_route_drops_the_operation_and_keeps_the_others() {
351        let registry = OpenApiRegistry::new(config_with_specs());
352
353        registry.register_route(
354            &Method::GET,
355            "/files/{*path}",
356            &OpenApiRouteConfig::default(),
357        );
358        registry.register_route(
359            &Method::PUT,
360            "/files/{*path}",
361            &OpenApiRouteConfig::default(),
362        );
363        registry.register_route(&Method::GET, "/users", &OpenApiRouteConfig::default());
364
365        registry.remove_route(&Method::GET, "/files/{*path}");
366
367        let doc = registry.document_by_name("v1").expect("v1 document");
368        assert!(!doc.paths["/files/{path}"].contains_key("get"));
369        assert!(doc.paths["/files/{path}"].contains_key("put"));
370
371        registry.remove_route(&Method::PUT, "/files/{*path}");
372        registry.remove_route(&Method::DELETE, "/never/registered");
373
374        let doc = registry.document_by_name("v1").expect("v1 document");
375        assert!(!doc.paths.contains_key("/files/{path}"));
376        assert!(doc.paths.contains_key("/users"));
377    }
378
379    #[test]
380    fn docs_for_reads_the_bound_docs_or_the_first_spec() {
381        let registry = OpenApiRegistry::new(config_with_specs());
382
383        assert_eq!(registry.docs_for(&OpenApiRouteConfig::default()), ["v1"]);
384        assert_eq!(
385            registry.docs_for(&OpenApiRouteConfig::default().with_docs(["admin", "v1"])),
386            ["admin", "v1"]
387        );
388    }
389
390    #[test]
391    fn describe_route_in_writes_to_the_given_docs_only() {
392        let registry = OpenApiRegistry::new(config_with_specs());
393        let cfg = OpenApiRouteConfig::default()
394            .with_docs(["v1", "admin"])
395            .with_summary("the rest");
396
397        registry.describe_route_in(&Method::GET, "/files/{*path}", &cfg, &["admin", "missing"]);
398
399        let v1 = registry.document_by_name("v1").expect("v1 document");
400        let admin = registry.document_by_name("admin").expect("admin document");
401        assert!(!v1.paths.contains_key("/files/{path}"));
402        assert!(admin.paths["/files/{path}"].contains_key("get"));
403    }
404
405    #[test]
406    fn rebind_route_moves_operation_to_target_doc() {
407        let registry = OpenApiRegistry::new(config_with_specs());
408
409        registry.register_route(&Method::POST, "/pets", &OpenApiRouteConfig::default());
410        registry.rebind_route(
411            &Method::POST,
412            "/pets",
413            &OpenApiRouteConfig::default().with_doc("admin"),
414        );
415
416        let v1_doc = registry.document_by_name("v1").expect("v1 document");
417        let admin_doc = registry.document_by_name("admin").expect("admin document");
418
419        assert!(!v1_doc.paths.contains_key("/pets"));
420        assert!(admin_doc.paths.contains_key("/pets"));
421        assert!(admin_doc.paths["/pets"].contains_key("post"));
422    }
423
424    #[test]
425    fn rebind_route_describes_the_route_the_config_describes() {
426        let registry = OpenApiRegistry::new(config_with_specs());
427
428        let described = OpenApiRouteConfig::default().with_summary("first registration");
429        registry.register_route(&Method::GET, "/users", &described);
430        registry.apply_route_config(&Method::GET, "/users", &described);
431
432        // What replaced it says nothing about a summary, so neither does the operation
433        registry.rebind_route(&Method::GET, "/users", &OpenApiRouteConfig::default());
434
435        let v1_doc = registry.document_by_name("v1").expect("v1 document");
436
437        assert!(v1_doc.paths["/users"]["get"].summary.is_none());
438    }
439
440    #[test]
441    fn rebind_route_removes_stale_operation_from_all_docs() {
442        let registry = OpenApiRegistry::new(config_with_specs());
443
444        let shared = OpenApiRouteConfig::default().with_docs(["v1", "admin"]);
445        registry.register_route(&Method::GET, "/shared", &shared);
446
447        let target = OpenApiRouteConfig::default().with_doc("v1");
448        registry.rebind_route(&Method::GET, "/shared", &target);
449
450        let v1_doc = registry.document_by_name("v1").expect("v1 document");
451        let admin_doc = registry.document_by_name("admin").expect("admin document");
452
453        assert!(v1_doc.paths["/shared"].contains_key("get"));
454        assert!(!admin_doc.paths.contains_key("/shared"));
455    }
456
457    #[test]
458    fn rebind_route_skips_excluded_paths() {
459        let registry = OpenApiRegistry::new(config_with_specs());
460
461        registry.rebind_route(
462            &Method::GET,
463            "/v1/openapi.json",
464            &OpenApiRouteConfig::default().with_doc("admin"),
465        );
466
467        let admin_doc = registry.document_by_name("admin").expect("admin document");
468        assert!(!admin_doc.paths.contains_key("/v1/openapi.json"));
469    }
470
471    #[test]
472    fn rebind_route_prunes_unreferenced_component_schemas() {
473        let registry =
474            OpenApiRegistry::new(OpenApiConfig::new().with_specs([OpenApiSpec::new("v1")]));
475
476        let first = OpenApiRouteConfig::default().with_response_schema(
477            200u16,
478            crate::schema::OpenApiSchema::object()
479                .with_title("User")
480                .with_property("name", crate::schema::OpenApiSchema::string()),
481        );
482        let second = OpenApiRouteConfig::default().with_response_schema(
483            200u16,
484            crate::schema::OpenApiSchema::object()
485                .with_title("User")
486                .with_property("id", crate::schema::OpenApiSchema::integer()),
487        );
488
489        registry.register_route(&Method::GET, "/users", &first);
490        registry.apply_route_config(&Method::GET, "/users", &first);
491
492        registry.rebind_route(&Method::GET, "/users", &second);
493
494        let v1_doc = registry.document_by_name("v1").expect("v1 document");
495
496        assert_eq!(v1_doc.components.schemas.len(), 1);
497        assert!(!v1_doc.components.schemas.contains_key("User"));
498        assert!(v1_doc.components.schemas.contains_key("User_2"));
499    }
500
501    #[test]
502    fn it_tests_normalization_for_excluded_paths() {
503        assert_eq!(
504            normalize_path_for_compare("/v1/openapi.json").as_str(),
505            "/v1/openapi.json"
506        );
507        assert_eq!(normalize_path_for_compare("openapi"), "/openapi");
508        assert_eq!(normalize_path_for_compare(""), "/");
509        assert_eq!(normalize_path_for_compare("/openapi/"), "/openapi");
510    }
511
512    #[test]
513    #[should_panic(expected = "OpenAPI spec names must be unique")]
514    fn registry_new_panics_on_duplicate_spec_names() {
515        let config = OpenApiConfig::new().with_specs([
516            OpenApiSpec::new("v1"),
517            OpenApiSpec::new("v1").with_path("/v1-alt/openapi.json"),
518        ]);
519
520        let _ = OpenApiRegistry::new(config);
521    }
522
523    #[test]
524    #[should_panic(expected = "OpenAPI spec paths must be unique after normalization")]
525    fn registry_new_panics_on_duplicate_normalized_spec_paths() {
526        let config = OpenApiConfig::new().with_specs([
527            OpenApiSpec::new("v1").with_path("docs/openapi.json"),
528            OpenApiSpec::new("admin").with_path("/docs/openapi.json/"),
529        ]);
530
531        let _ = OpenApiRegistry::new(config);
532    }
533}