1use 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#[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 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 pub fn register_route(&self, method: &Method, path: &str, cfg: &OpenApiRouteConfig) {
69 self.register_in(method, path, &self.docs_for(cfg));
70 }
71
72 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 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 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 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 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 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 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 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 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 pub fn document_by_name(&self, name: &str) -> Option<OpenApiDocument> {
311 self.lock().get(name).cloned()
312 }
313
314 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#[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 let p = if p.is_empty() { "/" } else { p };
372
373 let p = if p.starts_with('/') {
375 p.to_string()
376 } else {
377 format!("/{p}")
378 };
379
380 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 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}