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 let mut op_opt: Option<OpenApiOperation> = None;
124 for doc in docs.values_mut() {
125 if let Some(methods) = doc.paths.get_mut(&spec_path)
126 && let Some(op) = methods.remove(&method_lc)
127 {
128 op_opt = Some(op);
129 if methods.is_empty() {
130 doc.paths.remove(&spec_path);
131 }
132 }
133 }
134
135 let mut op = op_opt.unwrap_or_default();
136
137 if op.parameters.is_none() && !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_removes_stale_operation_from_all_docs() {
321 let registry = OpenApiRegistry::new(config_with_specs());
322
323 let shared = OpenApiRouteConfig::default().with_docs(["v1", "admin"]);
324 registry.register_route(&Method::GET, "/shared", &shared);
325
326 let target = OpenApiRouteConfig::default().with_doc("v1");
327 registry.rebind_route(&Method::GET, "/shared", &target);
328
329 let v1_doc = registry.document_by_name("v1").expect("v1 document");
330 let admin_doc = registry.document_by_name("admin").expect("admin document");
331
332 assert!(v1_doc.paths["/shared"].contains_key("get"));
333 assert!(!admin_doc.paths.contains_key("/shared"));
334 }
335
336 #[test]
337 fn rebind_route_skips_excluded_paths() {
338 let registry = OpenApiRegistry::new(config_with_specs());
339
340 registry.rebind_route(
341 &Method::GET,
342 "/v1/openapi.json",
343 &OpenApiRouteConfig::default().with_doc("admin"),
344 );
345
346 let admin_doc = registry.document_by_name("admin").expect("admin document");
347 assert!(!admin_doc.paths.contains_key("/v1/openapi.json"));
348 }
349
350 #[test]
351 fn rebind_route_prunes_unreferenced_component_schemas() {
352 let registry =
353 OpenApiRegistry::new(OpenApiConfig::new().with_specs([OpenApiSpec::new("v1")]));
354
355 let first = OpenApiRouteConfig::default().with_response_schema(
356 200u16,
357 crate::schema::OpenApiSchema::object()
358 .with_title("User")
359 .with_property("name", crate::schema::OpenApiSchema::string()),
360 );
361 let second = OpenApiRouteConfig::default().with_response_schema(
362 200u16,
363 crate::schema::OpenApiSchema::object()
364 .with_title("User")
365 .with_property("id", crate::schema::OpenApiSchema::integer()),
366 );
367
368 registry.register_route(&Method::GET, "/users", &first);
369 registry.apply_route_config(&Method::GET, "/users", &first);
370
371 registry.rebind_route(&Method::GET, "/users", &second);
372
373 let v1_doc = registry.document_by_name("v1").expect("v1 document");
374
375 assert_eq!(v1_doc.components.schemas.len(), 1);
376 assert!(!v1_doc.components.schemas.contains_key("User"));
377 assert!(v1_doc.components.schemas.contains_key("User_2"));
378 }
379
380 #[test]
381 fn it_tests_normalization_for_excluded_paths() {
382 assert_eq!(
383 normalize_path_for_compare("/v1/openapi.json").as_str(),
384 "/v1/openapi.json"
385 );
386 assert_eq!(normalize_path_for_compare("openapi"), "/openapi");
387 assert_eq!(normalize_path_for_compare(""), "/");
388 assert_eq!(normalize_path_for_compare("/openapi/"), "/openapi");
389 }
390
391 #[test]
392 #[should_panic(expected = "OpenAPI spec names must be unique")]
393 fn registry_new_panics_on_duplicate_spec_names() {
394 let config = OpenApiConfig::new().with_specs([
395 OpenApiSpec::new("v1"),
396 OpenApiSpec::new("v1").with_path("/v1-alt/openapi.json"),
397 ]);
398
399 let _ = OpenApiRegistry::new(config);
400 }
401
402 #[test]
403 #[should_panic(expected = "OpenAPI spec paths must be unique after normalization")]
404 fn registry_new_panics_on_duplicate_normalized_spec_paths() {
405 let config = OpenApiConfig::new().with_specs([
406 OpenApiSpec::new("v1").with_path("docs/openapi.json"),
407 OpenApiSpec::new("admin").with_path("/docs/openapi.json/"),
408 ]);
409
410 let _ = OpenApiRegistry::new(config);
411 }
412}