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::{is_same_hierarchy, normalize_openapi_path, rename_path_parameters},
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    /// Describes a route in `docs` alone, under the path `template` spells, replacing the
88    /// operation its method has there.
89    ///
90    /// `template` is a pattern at the route's own position that names its parameters
91    /// otherwise - another route's. A document takes one set of names for one templated
92    /// path, so a route at a position other routes name differently is described under
93    /// theirs: its path parameters are renamed position by position, after the ones its
94    /// configuration describes are merged in under the names the route was written with.
95    /// A name no document carries is skipped.
96    ///
97    /// The operation is built from `cfg` alone, as [`Self::rebind_route`] builds it.
98    pub fn describe_route_as(
99        &self,
100        method: &Method,
101        path: &str,
102        template: &str,
103        cfg: &OpenApiRouteConfig,
104        docs: &[&str],
105    ) {
106        if self.is_excluded_path(path) {
107            return;
108        }
109
110        let (_, own_params) = normalize_openapi_path(path);
111        let (spec_path, template_params) = normalize_openapi_path(template);
112        let method_lc = method.as_str().to_ascii_lowercase();
113
114        let mut documents = self.lock();
115        for doc_name in docs {
116            let Some(doc) = documents.get_mut(*doc_name) else {
117                continue;
118            };
119
120            let mut op = OpenApiOperation::default();
121            if !own_params.is_empty() {
122                op.parameters = Some(own_params.clone());
123            }
124
125            cfg.apply_to_operation(&mut op, &mut doc.components.schemas);
126
127            if let Some(parameters) = op.parameters.as_mut() {
128                rename_path_parameters(parameters, &own_params, &template_params);
129            }
130
131            doc.paths
132                .entry(spec_path.clone())
133                .or_default()
134                .insert(method_lc.clone(), op);
135        }
136    }
137
138    /// Removes every operation at the position `path` is at from every spec, whatever the
139    /// parameters there are named - see [`Self::describe_route_as`].
140    pub fn remove_position(&self, path: &str) {
141        if self.is_excluded_path(path) {
142            return;
143        }
144
145        let (spec_path, _) = normalize_openapi_path(path);
146
147        let mut docs = self.lock();
148        for doc in docs.values_mut() {
149            doc.paths
150                .retain(|other, _| !is_same_hierarchy(other, &spec_path));
151            doc.prune_unreferenced_components();
152        }
153    }
154
155    /// Returns the names of the documents a route configured with `cfg` is described in: the
156    /// ones it is bound to, or the first spec when it is bound to none.
157    pub fn docs_for<'a>(&'a self, cfg: &'a OpenApiRouteConfig) -> Vec<&'a str> {
158        if let Some(docs) = cfg.docs() {
159            docs.iter().map(|s| s.as_str()).collect()
160        } else {
161            self.specs
162                .first()
163                .map(|s| vec![s.name.as_str()])
164                .unwrap_or_default()
165        }
166    }
167
168    fn register_in(&self, method: &Method, path: &str, targets: &[&str]) {
169        if self.is_excluded_path(path) {
170            return;
171        }
172
173        let (spec_path, path_params) = normalize_openapi_path(path);
174
175        let mut docs = self.lock();
176        let method = method.as_str().to_ascii_lowercase();
177
178        for doc_name in targets {
179            if let Some(doc) = docs.get_mut(*doc_name) {
180                let entry = doc.paths.entry(spec_path.clone()).or_default();
181
182                let op = entry
183                    .entry(method.clone())
184                    .or_insert_with(OpenApiOperation::default);
185
186                if op.parameters.is_none() && !path_params.is_empty() {
187                    op.parameters = Some(path_params.clone());
188                }
189            }
190        }
191    }
192
193    /// Rebinds route to another spec.
194    pub fn rebind_route(&self, method: &Method, path: &str, cfg: &OpenApiRouteConfig) {
195        if self.is_excluded_path(path) {
196            return;
197        }
198
199        let (spec_path, path_params) = normalize_openapi_path(path);
200
201        let method_lc = method.as_str().to_ascii_lowercase();
202        let targets = self.docs_for(cfg);
203
204        let mut docs = self.lock();
205
206        let mut valid_targets: Vec<&str> = targets
207            .into_iter()
208            .filter(|name| docs.contains_key(*name))
209            .collect();
210
211        // If user specified docs but none exist -> do not drop the route from all specs.
212        if valid_targets.is_empty() {
213            #[cfg(debug_assertions)]
214            {
215                eprintln!(
216                    "OpenAPI: rebind_route ignored: no such spec(s) for route {method_lc} {spec_path}"
217                );
218            }
219            return;
220        }
221
222        for doc in docs.values_mut() {
223            remove_operation(doc, &spec_path, &method_lc);
224        }
225
226        // Built from `cfg` alone rather than from the operation that was there: `cfg`
227        // describes the route as it stands, and a field it no longer carries - a summary
228        // written for a handler that has since been replaced - is not part of it
229        let mut op = OpenApiOperation::default();
230
231        if !path_params.is_empty() {
232            op.parameters = Some(path_params.clone());
233        }
234
235        for name in valid_targets.drain(..) {
236            let Some(doc) = docs.get_mut(name) else {
237                continue;
238            };
239
240            cfg.apply_to_operation(&mut op, &mut doc.components.schemas);
241
242            doc.paths
243                .entry(spec_path.clone())
244                .or_default()
245                .insert(method_lc.clone(), op.clone());
246        }
247
248        for doc in docs.values_mut() {
249            doc.prune_unreferenced_components();
250        }
251    }
252
253    /// Removes a route's operation from every spec.
254    ///
255    /// A route that is not in a spec is left out of it, so removing one that was never
256    /// registered does nothing.
257    pub fn remove_route(&self, method: &Method, path: &str) {
258        if self.is_excluded_path(path) {
259            return;
260        }
261
262        let (spec_path, _) = normalize_openapi_path(path);
263        let method_lc = method.as_str().to_ascii_lowercase();
264
265        let mut docs = self.lock();
266        for doc in docs.values_mut() {
267            remove_operation(doc, &spec_path, &method_lc);
268            doc.prune_unreferenced_components();
269        }
270    }
271
272    /// Applies route configuration
273    pub fn apply_route_config(&self, method: &Method, path: &str, cfg: &OpenApiRouteConfig) {
274        self.apply_in(method, path, cfg, &self.docs_for(cfg));
275    }
276
277    fn apply_in(&self, method: &Method, path: &str, cfg: &OpenApiRouteConfig, targets: &[&str]) {
278        if self.is_excluded_path(path) {
279            return;
280        }
281
282        let (spec_path, path_params) = normalize_openapi_path(path);
283
284        let mut docs = self.lock();
285        let method_lc = method.as_str().to_ascii_lowercase();
286
287        for doc_name in targets {
288            let Some(doc) = docs.get_mut(*doc_name) else {
289                continue;
290            };
291
292            let OpenApiDocument {
293                paths, components, ..
294            } = doc;
295
296            let entry = paths.entry(spec_path.clone()).or_default();
297            let op = entry
298                .entry(method_lc.clone())
299                .or_insert_with(OpenApiOperation::default);
300
301            if op.parameters.is_none() && !path_params.is_empty() {
302                op.parameters = Some(path_params.clone());
303            }
304
305            cfg.apply_to_operation(op, &mut components.schemas);
306        }
307    }
308
309    /// Returns OpenAPI document by spec name.
310    pub fn document_by_name(&self, name: &str) -> Option<OpenApiDocument> {
311        self.lock().get(name).cloned()
312    }
313
314    /// Returns a list of defined specs
315    pub fn specs(&self) -> &[OpenApiSpec] {
316        &self.specs
317    }
318
319    #[inline]
320    fn is_excluded_path(&self, path: &str) -> bool {
321        let p = normalize_path_for_compare(path);
322
323        if self.ui_enabled && p == normalize_path_for_compare(&self.ui_path) {
324            return true;
325        }
326
327        self.specs
328            .iter()
329            .any(|s| p == normalize_path_for_compare(&s.spec_path))
330    }
331
332    fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, OpenApiDocument>> {
333        self.inner.lock().expect("openapi registry lock poisoned")
334    }
335}
336
337/// Removes the operation `method` has at `spec_path`, and the path with it once no method is
338/// left there
339#[inline]
340fn remove_operation(doc: &mut OpenApiDocument, spec_path: &str, method: &str) {
341    if let Some(methods) = doc.paths.get_mut(spec_path)
342        && methods.remove(method).is_some()
343        && methods.is_empty()
344    {
345        doc.paths.remove(spec_path);
346    }
347}
348
349fn validate_specs(specs: &[OpenApiSpec]) {
350    let mut names = HashSet::with_capacity(specs.len());
351    let mut paths = HashSet::with_capacity(specs.len());
352
353    for spec in specs {
354        assert!(
355            names.insert(spec.name.clone()),
356            "OpenAPI spec names must be unique; duplicate name: {}",
357            spec.name
358        );
359
360        let normalized_path = normalize_path_for_compare(&spec.spec_path);
361        assert!(
362            paths.insert(normalized_path.clone()),
363            "OpenAPI spec paths must be unique after normalization; duplicate path: {normalized_path}"
364        );
365    }
366}
367
368#[inline]
369fn normalize_path_for_compare(p: &str) -> String {
370    // treat "" as "/"
371    let p = if p.is_empty() { "/" } else { p };
372
373    // ensure leading slash
374    let p = if p.starts_with('/') {
375        p.to_string()
376    } else {
377        format!("/{p}")
378    };
379
380    // drop trailing slash (except root)
381    if p.len() > 1 && p.ends_with('/') {
382        p.trim_end_matches('/').to_string()
383    } else {
384        p
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    fn config_with_specs() -> OpenApiConfig {
393        OpenApiConfig::new()
394            .with_specs([OpenApiSpec::new("v1"), OpenApiSpec::new("admin")])
395            .with_ui()
396            .with_ui_path("/openapi")
397    }
398
399    #[test]
400    fn register_route_skips_ui_and_spec_paths() {
401        let registry = OpenApiRegistry::new(config_with_specs());
402
403        registry.register_route(&Method::GET, "/openapi", &OpenApiRouteConfig::default());
404        registry.register_route(
405            &Method::GET,
406            "v1/openapi.json",
407            &OpenApiRouteConfig::default(),
408        );
409        registry.register_route(&Method::GET, "/users", &OpenApiRouteConfig::default());
410
411        let v1_doc = registry.document_by_name("v1").expect("v1 document");
412        assert!(v1_doc.paths.contains_key("/users"));
413        assert!(!v1_doc.paths.contains_key("/openapi"));
414        assert!(!v1_doc.paths.contains_key("/sv1/openapi.json"));
415    }
416
417    #[test]
418    fn remove_route_drops_the_operation_and_keeps_the_others() {
419        let registry = OpenApiRegistry::new(config_with_specs());
420
421        registry.register_route(
422            &Method::GET,
423            "/files/{*path}",
424            &OpenApiRouteConfig::default(),
425        );
426        registry.register_route(
427            &Method::PUT,
428            "/files/{*path}",
429            &OpenApiRouteConfig::default(),
430        );
431        registry.register_route(&Method::GET, "/users", &OpenApiRouteConfig::default());
432
433        registry.remove_route(&Method::GET, "/files/{*path}");
434
435        let doc = registry.document_by_name("v1").expect("v1 document");
436        assert!(!doc.paths["/files/{path}"].contains_key("get"));
437        assert!(doc.paths["/files/{path}"].contains_key("put"));
438
439        registry.remove_route(&Method::PUT, "/files/{*path}");
440        registry.remove_route(&Method::DELETE, "/never/registered");
441
442        let doc = registry.document_by_name("v1").expect("v1 document");
443        assert!(!doc.paths.contains_key("/files/{path}"));
444        assert!(doc.paths.contains_key("/users"));
445    }
446
447    #[test]
448    fn docs_for_reads_the_bound_docs_or_the_first_spec() {
449        let registry = OpenApiRegistry::new(config_with_specs());
450
451        assert_eq!(registry.docs_for(&OpenApiRouteConfig::default()), ["v1"]);
452        assert_eq!(
453            registry.docs_for(&OpenApiRouteConfig::default().with_docs(["admin", "v1"])),
454            ["admin", "v1"]
455        );
456    }
457
458    #[test]
459    fn describe_route_in_writes_to_the_given_docs_only() {
460        let registry = OpenApiRegistry::new(config_with_specs());
461        let cfg = OpenApiRouteConfig::default()
462            .with_docs(["v1", "admin"])
463            .with_summary("the rest");
464
465        registry.describe_route_in(&Method::GET, "/files/{*path}", &cfg, &["admin", "missing"]);
466
467        let v1 = registry.document_by_name("v1").expect("v1 document");
468        let admin = registry.document_by_name("admin").expect("admin document");
469        assert!(!v1.paths.contains_key("/files/{path}"));
470        assert!(admin.paths["/files/{path}"].contains_key("get"));
471    }
472
473    #[test]
474    fn describe_route_as_describes_a_route_under_the_template_names() {
475        let registry = OpenApiRegistry::new(config_with_specs());
476
477        registry.describe_route_as(
478            &Method::POST,
479            "/users/{name:integer}/posts/{*rest}",
480            "/users/{id}/posts/{post}",
481            &OpenApiRouteConfig::default().with_summary("write"),
482            &["admin", "missing"],
483        );
484
485        let v1 = registry.document_by_name("v1").expect("v1 document");
486        let admin = registry.document_by_name("admin").expect("admin document");
487        assert!(v1.paths.is_empty());
488
489        let op = &admin.paths["/users/{id}/posts/{post}"]["post"];
490        assert_eq!(op.summary.as_deref(), Some("write"));
491
492        let parameters = op
493            .parameters
494            .as_deref()
495            .expect("path parameters")
496            .iter()
497            .map(|p| (p.name.as_str(), p.schema.schema_type.as_deref()))
498            .collect::<Vec<_>>();
499        assert_eq!(
500            parameters,
501            [("id", Some("integer")), ("post", Some("string"))]
502        );
503    }
504
505    #[test]
506    fn describe_route_as_replaces_the_operation_of_its_method() {
507        let registry = OpenApiRegistry::new(config_with_specs());
508
509        let first = OpenApiRouteConfig::default().with_summary("first");
510        registry.describe_route_as(&Method::GET, "/users/{id}", "/users/{id}", &first, &["v1"]);
511        registry.describe_route_as(
512            &Method::GET,
513            "/users/{id}",
514            "/users/{id}",
515            &OpenApiRouteConfig::default(),
516            &["v1"],
517        );
518
519        let v1 = registry.document_by_name("v1").expect("v1 document");
520        assert!(v1.paths["/users/{id}"]["get"].summary.is_none());
521    }
522
523    #[test]
524    fn remove_position_removes_every_spelling_of_one_position() {
525        let registry = OpenApiRegistry::new(config_with_specs());
526        let both = OpenApiRouteConfig::default().with_docs(["v1", "admin"]);
527
528        registry.register_route(&Method::GET, "/users/{id}", &both);
529        registry.register_route(&Method::POST, "/users/{name}", &both);
530        registry.register_route(&Method::PUT, "/users/{*rest}", &both);
531        registry.register_route(&Method::GET, "/users/me", &both);
532        registry.register_route(&Method::GET, "/users/{id}/posts", &both);
533
534        registry.remove_position("/users/{anything}");
535
536        for doc in ["v1", "admin"] {
537            let doc = registry.document_by_name(doc).expect("document");
538            let paths = doc.paths.keys().map(String::as_str).collect::<Vec<_>>();
539            assert_eq!(paths, ["/users/me", "/users/{id}/posts"]);
540        }
541    }
542
543    #[test]
544    fn rebind_route_moves_operation_to_target_doc() {
545        let registry = OpenApiRegistry::new(config_with_specs());
546
547        registry.register_route(&Method::POST, "/pets", &OpenApiRouteConfig::default());
548        registry.rebind_route(
549            &Method::POST,
550            "/pets",
551            &OpenApiRouteConfig::default().with_doc("admin"),
552        );
553
554        let v1_doc = registry.document_by_name("v1").expect("v1 document");
555        let admin_doc = registry.document_by_name("admin").expect("admin document");
556
557        assert!(!v1_doc.paths.contains_key("/pets"));
558        assert!(admin_doc.paths.contains_key("/pets"));
559        assert!(admin_doc.paths["/pets"].contains_key("post"));
560    }
561
562    #[test]
563    fn rebind_route_describes_the_route_the_config_describes() {
564        let registry = OpenApiRegistry::new(config_with_specs());
565
566        let described = OpenApiRouteConfig::default().with_summary("first registration");
567        registry.register_route(&Method::GET, "/users", &described);
568        registry.apply_route_config(&Method::GET, "/users", &described);
569
570        // What replaced it says nothing about a summary, so neither does the operation
571        registry.rebind_route(&Method::GET, "/users", &OpenApiRouteConfig::default());
572
573        let v1_doc = registry.document_by_name("v1").expect("v1 document");
574
575        assert!(v1_doc.paths["/users"]["get"].summary.is_none());
576    }
577
578    #[test]
579    fn rebind_route_removes_stale_operation_from_all_docs() {
580        let registry = OpenApiRegistry::new(config_with_specs());
581
582        let shared = OpenApiRouteConfig::default().with_docs(["v1", "admin"]);
583        registry.register_route(&Method::GET, "/shared", &shared);
584
585        let target = OpenApiRouteConfig::default().with_doc("v1");
586        registry.rebind_route(&Method::GET, "/shared", &target);
587
588        let v1_doc = registry.document_by_name("v1").expect("v1 document");
589        let admin_doc = registry.document_by_name("admin").expect("admin document");
590
591        assert!(v1_doc.paths["/shared"].contains_key("get"));
592        assert!(!admin_doc.paths.contains_key("/shared"));
593    }
594
595    #[test]
596    fn rebind_route_skips_excluded_paths() {
597        let registry = OpenApiRegistry::new(config_with_specs());
598
599        registry.rebind_route(
600            &Method::GET,
601            "/v1/openapi.json",
602            &OpenApiRouteConfig::default().with_doc("admin"),
603        );
604
605        let admin_doc = registry.document_by_name("admin").expect("admin document");
606        assert!(!admin_doc.paths.contains_key("/v1/openapi.json"));
607    }
608
609    #[test]
610    fn rebind_route_prunes_unreferenced_component_schemas() {
611        let registry =
612            OpenApiRegistry::new(OpenApiConfig::new().with_specs([OpenApiSpec::new("v1")]));
613
614        let first = OpenApiRouteConfig::default().with_response_schema(
615            200u16,
616            crate::schema::OpenApiSchema::object()
617                .with_title("User")
618                .with_property("name", crate::schema::OpenApiSchema::string()),
619        );
620        let second = OpenApiRouteConfig::default().with_response_schema(
621            200u16,
622            crate::schema::OpenApiSchema::object()
623                .with_title("User")
624                .with_property("id", crate::schema::OpenApiSchema::integer()),
625        );
626
627        registry.register_route(&Method::GET, "/users", &first);
628        registry.apply_route_config(&Method::GET, "/users", &first);
629
630        registry.rebind_route(&Method::GET, "/users", &second);
631
632        let v1_doc = registry.document_by_name("v1").expect("v1 document");
633
634        assert_eq!(v1_doc.components.schemas.len(), 1);
635        assert!(!v1_doc.components.schemas.contains_key("User"));
636        assert!(v1_doc.components.schemas.contains_key("User_2"));
637    }
638
639    #[test]
640    fn it_tests_normalization_for_excluded_paths() {
641        assert_eq!(
642            normalize_path_for_compare("/v1/openapi.json").as_str(),
643            "/v1/openapi.json"
644        );
645        assert_eq!(normalize_path_for_compare("openapi"), "/openapi");
646        assert_eq!(normalize_path_for_compare(""), "/");
647        assert_eq!(normalize_path_for_compare("/openapi/"), "/openapi");
648    }
649
650    #[test]
651    #[should_panic(expected = "OpenAPI spec names must be unique")]
652    fn registry_new_panics_on_duplicate_spec_names() {
653        let config = OpenApiConfig::new().with_specs([
654            OpenApiSpec::new("v1"),
655            OpenApiSpec::new("v1").with_path("/v1-alt/openapi.json"),
656        ]);
657
658        let _ = OpenApiRegistry::new(config);
659    }
660
661    #[test]
662    #[should_panic(expected = "OpenAPI spec paths must be unique after normalization")]
663    fn registry_new_panics_on_duplicate_normalized_spec_paths() {
664        let config = OpenApiConfig::new().with_specs([
665            OpenApiSpec::new("v1").with_path("docs/openapi.json"),
666            OpenApiSpec::new("admin").with_path("/docs/openapi.json/"),
667        ]);
668
669        let _ = OpenApiRegistry::new(config);
670    }
671}