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 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 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 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 for doc in docs.values_mut() {
124 if let Some(methods) = doc.paths.get_mut(&spec_path)
125 && methods.remove(&method_lc).is_some()
126 && methods.is_empty()
127 {
128 doc.paths.remove(&spec_path);
129 }
130 }
131
132 let mut op = OpenApiOperation::default();
136
137 if !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 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 pub fn document_by_name(&self, name: &str) -> Option<OpenApiDocument> {
195 self.lock().get(name).cloned()
196 }
197
198 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 let p = if p.is_empty() { "/" } else { p };
255
256 let p = if p.starts_with('/') {
258 p.to_string()
259 } else {
260 format!("/{p}")
261 };
262
263 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_describes_the_route_the_config_describes() {
321 let registry = OpenApiRegistry::new(config_with_specs());
322
323 let described = OpenApiRouteConfig::default().with_summary("first registration");
324 registry.register_route(&Method::GET, "/users", &described);
325 registry.apply_route_config(&Method::GET, "/users", &described);
326
327 registry.rebind_route(&Method::GET, "/users", &OpenApiRouteConfig::default());
329
330 let v1_doc = registry.document_by_name("v1").expect("v1 document");
331
332 assert!(v1_doc.paths["/users"]["get"].summary.is_none());
333 }
334
335 #[test]
336 fn rebind_route_removes_stale_operation_from_all_docs() {
337 let registry = OpenApiRegistry::new(config_with_specs());
338
339 let shared = OpenApiRouteConfig::default().with_docs(["v1", "admin"]);
340 registry.register_route(&Method::GET, "/shared", &shared);
341
342 let target = OpenApiRouteConfig::default().with_doc("v1");
343 registry.rebind_route(&Method::GET, "/shared", &target);
344
345 let v1_doc = registry.document_by_name("v1").expect("v1 document");
346 let admin_doc = registry.document_by_name("admin").expect("admin document");
347
348 assert!(v1_doc.paths["/shared"].contains_key("get"));
349 assert!(!admin_doc.paths.contains_key("/shared"));
350 }
351
352 #[test]
353 fn rebind_route_skips_excluded_paths() {
354 let registry = OpenApiRegistry::new(config_with_specs());
355
356 registry.rebind_route(
357 &Method::GET,
358 "/v1/openapi.json",
359 &OpenApiRouteConfig::default().with_doc("admin"),
360 );
361
362 let admin_doc = registry.document_by_name("admin").expect("admin document");
363 assert!(!admin_doc.paths.contains_key("/v1/openapi.json"));
364 }
365
366 #[test]
367 fn rebind_route_prunes_unreferenced_component_schemas() {
368 let registry =
369 OpenApiRegistry::new(OpenApiConfig::new().with_specs([OpenApiSpec::new("v1")]));
370
371 let first = OpenApiRouteConfig::default().with_response_schema(
372 200u16,
373 crate::schema::OpenApiSchema::object()
374 .with_title("User")
375 .with_property("name", crate::schema::OpenApiSchema::string()),
376 );
377 let second = OpenApiRouteConfig::default().with_response_schema(
378 200u16,
379 crate::schema::OpenApiSchema::object()
380 .with_title("User")
381 .with_property("id", crate::schema::OpenApiSchema::integer()),
382 );
383
384 registry.register_route(&Method::GET, "/users", &first);
385 registry.apply_route_config(&Method::GET, "/users", &first);
386
387 registry.rebind_route(&Method::GET, "/users", &second);
388
389 let v1_doc = registry.document_by_name("v1").expect("v1 document");
390
391 assert_eq!(v1_doc.components.schemas.len(), 1);
392 assert!(!v1_doc.components.schemas.contains_key("User"));
393 assert!(v1_doc.components.schemas.contains_key("User_2"));
394 }
395
396 #[test]
397 fn it_tests_normalization_for_excluded_paths() {
398 assert_eq!(
399 normalize_path_for_compare("/v1/openapi.json").as_str(),
400 "/v1/openapi.json"
401 );
402 assert_eq!(normalize_path_for_compare("openapi"), "/openapi");
403 assert_eq!(normalize_path_for_compare(""), "/");
404 assert_eq!(normalize_path_for_compare("/openapi/"), "/openapi");
405 }
406
407 #[test]
408 #[should_panic(expected = "OpenAPI spec names must be unique")]
409 fn registry_new_panics_on_duplicate_spec_names() {
410 let config = OpenApiConfig::new().with_specs([
411 OpenApiSpec::new("v1"),
412 OpenApiSpec::new("v1").with_path("/v1-alt/openapi.json"),
413 ]);
414
415 let _ = OpenApiRegistry::new(config);
416 }
417
418 #[test]
419 #[should_panic(expected = "OpenAPI spec paths must be unique after normalization")]
420 fn registry_new_panics_on_duplicate_normalized_spec_paths() {
421 let config = OpenApiConfig::new().with_specs([
422 OpenApiSpec::new("v1").with_path("docs/openapi.json"),
423 OpenApiSpec::new("admin").with_path("/docs/openapi.json/"),
424 ]);
425
426 let _ = OpenApiRegistry::new(config);
427 }
428}