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::normalize_openapi_path,
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 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 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 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 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 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 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 pub fn document_by_name(&self, name: &str) -> Option<OpenApiDocument> {
243 self.lock().get(name).cloned()
244 }
245
246 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#[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 let p = if p.is_empty() { "/" } else { p };
304
305 let p = if p.starts_with('/') {
307 p.to_string()
308 } else {
309 format!("/{p}")
310 };
311
312 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 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}