reinhardt_rest/versioning.rs
1//! # Reinhardt Versioning
2//!
3//! API versioning strategies for Reinhardt framework.
4//!
5//! ## Features
6//!
7//! - **AcceptHeaderVersioning**: Version from Accept header (e.g., `Accept: application/json; version=1.0`)
8//! - **URLPathVersioning**: Version from URL path (e.g., `/v1/users/`)
9//! - **NamespaceVersioning**: Version from URL namespace
10//! - **HostNameVersioning**: Version from subdomain (e.g., `v1.api.example.com`)
11//! - **QueryParameterVersioning**: Version from query parameter (e.g., `?version=1.0`)
12//! - **VersioningMiddleware**: Automatic version detection middleware
13//!
14//! ## Example
15//!
16//! ```rust
17//! use reinhardt_rest::versioning::{BaseVersioning, AcceptHeaderVersioning, QueryParameterVersioning};
18//! use reinhardt_rest::versioning::{VersioningMiddleware, RequestVersionExt};
19//!
20//! // Accept header versioning
21//! let accept_versioning = AcceptHeaderVersioning::new()
22//! .with_default_version("1.0")
23//! .with_allowed_versions(vec!["1.0", "2.0"]);
24//!
25//! // Query parameter versioning
26//! let query_versioning = QueryParameterVersioning::new()
27//! .with_version_param("v")
28//! .with_default_version("1.0");
29//!
30//! // Middleware for automatic version detection
31//! let middleware = VersioningMiddleware::new(accept_versioning);
32//! ```
33
34pub mod config;
35pub mod handler;
36pub mod middleware;
37pub mod reverse;
38pub mod settings;
39
40use async_trait::async_trait;
41pub use config::{VersioningConfig, VersioningManager, VersioningStrategy};
42pub use handler::{
43 ConfigurableVersionedHandler, SimpleVersionedHandler, VersionResponseBuilder, VersionedHandler,
44 VersionedHandlerBuilder, VersionedHandlerWrapper,
45};
46pub use middleware::{ApiVersion, RequestVersionExt, VersioningMiddleware};
47use regex::Regex;
48use reinhardt_core::exception::{Error, Result};
49use reinhardt_http::Request;
50pub use reverse::{
51 ApiDocFormat, ApiDocUrlBuilder, UrlReverseManager, VersionedUrlBuilder,
52 VersioningStrategy as ReverseVersioningStrategy,
53};
54pub use settings::VersioningSettings;
55use std::collections::{HashMap, HashSet};
56use std::sync::OnceLock;
57use thiserror::Error as ThisError;
58
59/// Errors that can occur during API version determination.
60#[derive(Debug, ThisError)]
61pub enum VersioningError {
62 /// The Accept header does not contain a valid version.
63 #[error("Invalid version in Accept header")]
64 InvalidAcceptHeader,
65
66 /// The URL path does not contain a valid version segment.
67 #[error("Invalid version in URL path")]
68 InvalidURLPath,
69
70 /// The URL namespace does not contain a valid version.
71 #[error("Invalid version in URL namespace")]
72 InvalidNamespace,
73
74 /// The hostname does not contain a valid version subdomain.
75 #[error("Invalid version in hostname")]
76 InvalidHostname,
77
78 /// The query parameter does not contain a valid version.
79 #[error("Invalid version in query parameter")]
80 InvalidQueryParameter,
81
82 /// The requested version is not in the allowed versions list.
83 #[error("Version not allowed: {0}")]
84 VersionNotAllowed(String),
85}
86
87/// Base trait for API versioning strategies
88#[async_trait]
89pub trait BaseVersioning: Send + Sync {
90 /// Determine the API version from the request
91 async fn determine_version(&self, request: &Request) -> Result<String>;
92
93 /// Get the default version
94 fn default_version(&self) -> Option<&str>;
95
96 /// Get allowed versions
97 fn allowed_versions(&self) -> Option<&HashSet<String>>;
98
99 /// Check if a version is allowed
100 fn is_allowed_version(&self, version: &str) -> bool {
101 if let Some(allowed) = self.allowed_versions() {
102 if allowed.is_empty() {
103 return true;
104 }
105 return allowed.contains(version) || (self.default_version() == Some(version));
106 }
107 true
108 }
109
110 /// Get the version parameter name
111 fn version_param(&self) -> &str {
112 "version"
113 }
114}
115
116/// Accept header versioning
117///
118/// Example: `Accept: application/json; version=1.0`
119#[derive(Debug, Clone)]
120pub struct AcceptHeaderVersioning {
121 /// The fallback version when no version is specified in the Accept header.
122 pub default_version: Option<String>,
123 /// The set of allowed API versions.
124 pub allowed_versions: HashSet<String>,
125 /// The parameter name to look for in the Accept header (default: `"version"`).
126 pub version_param: String,
127}
128
129impl AcceptHeaderVersioning {
130 /// Create a new AcceptHeaderVersioning instance
131 ///
132 /// # Examples
133 ///
134 /// ```
135 /// use reinhardt_rest::versioning::{AcceptHeaderVersioning, BaseVersioning};
136 ///
137 /// let versioning = AcceptHeaderVersioning::new();
138 /// assert_eq!(versioning.default_version.as_deref(), None);
139 /// assert_eq!(versioning.version_param.as_str(), "version");
140 /// ```
141 pub fn new() -> Self {
142 Self {
143 default_version: None,
144 allowed_versions: HashSet::new(),
145 version_param: "version".to_string(),
146 }
147 }
148 /// Set the default version to use when no version is specified
149 ///
150 /// # Examples
151 ///
152 /// ```
153 /// use reinhardt_rest::versioning::{AcceptHeaderVersioning, BaseVersioning};
154 ///
155 /// let versioning = AcceptHeaderVersioning::new()
156 /// .with_default_version("1.0");
157 /// assert_eq!(versioning.default_version.as_deref(), Some("1.0"));
158 /// ```
159 pub fn with_default_version(mut self, version: impl Into<String>) -> Self {
160 self.default_version = Some(version.into());
161 self
162 }
163 /// Set the allowed versions
164 ///
165 /// # Examples
166 ///
167 /// ```
168 /// use reinhardt_rest::versioning::{AcceptHeaderVersioning, BaseVersioning};
169 ///
170 /// let versioning = AcceptHeaderVersioning::new()
171 /// .with_allowed_versions(vec!["1.0", "2.0", "3.0"]);
172 /// assert!(versioning.is_allowed_version("1.0"));
173 /// assert!(versioning.is_allowed_version("2.0"));
174 /// assert!(!versioning.is_allowed_version("4.0"));
175 /// ```
176 pub fn with_allowed_versions(mut self, versions: Vec<impl Into<String>>) -> Self {
177 self.allowed_versions = versions.into_iter().map(|v| v.into()).collect();
178 self
179 }
180 /// Set the version parameter name to look for in the Accept header
181 ///
182 /// # Examples
183 ///
184 /// ```
185 /// use reinhardt_rest::versioning::{AcceptHeaderVersioning, BaseVersioning};
186 ///
187 /// let versioning = AcceptHeaderVersioning::new()
188 /// .with_version_param("api-version");
189 /// assert_eq!(versioning.version_param.as_str(), "api-version");
190 /// ```
191 pub fn with_version_param(mut self, param: impl Into<String>) -> Self {
192 self.version_param = param.into();
193 self
194 }
195}
196
197impl Default for AcceptHeaderVersioning {
198 fn default() -> Self {
199 Self::new()
200 }
201}
202
203#[async_trait]
204impl BaseVersioning for AcceptHeaderVersioning {
205 async fn determine_version(&self, request: &Request) -> Result<String> {
206 // Parse Accept header for version parameter
207 if let Some(accept) = request.headers.get("accept") {
208 let accept_str = accept
209 .to_str()
210 .map_err(|_| Error::Validation(VersioningError::InvalidAcceptHeader.to_string()))?;
211
212 // Parse media type parameters
213 if let Some(params_start) = accept_str.find(';') {
214 let params = &accept_str[params_start + 1..];
215 for param in params.split(';') {
216 let param = param.trim();
217 if let Some((key, value)) = param.split_once('=')
218 && key.trim() == self.version_param
219 {
220 let version = value.trim().trim_matches('"');
221 if self.is_allowed_version(version) {
222 return Ok(version.to_owned());
223 } else {
224 // Avoid intermediate String allocation from VersionNotAllowed(String).to_string()
225 return Err(Error::Validation(format!(
226 "Version not allowed: {version}"
227 )));
228 }
229 }
230 }
231 }
232 }
233
234 // Return default version if no version in header.
235 // Use as_deref().to_owned() instead of clone().unwrap_or_else(...) to skip
236 // cloning the Option<String> wrapper. The final String allocation to satisfy
237 // the Result<String> return type is unavoidable.
238 Ok(self.default_version.as_deref().unwrap_or("1.0").to_owned())
239 }
240
241 fn default_version(&self) -> Option<&str> {
242 self.default_version.as_deref()
243 }
244
245 fn allowed_versions(&self) -> Option<&HashSet<String>> {
246 Some(&self.allowed_versions)
247 }
248
249 fn version_param(&self) -> &str {
250 &self.version_param
251 }
252}
253
254/// URL path versioning
255///
256/// Example: `/v1/users/` or `/api/v2/users/`
257#[derive(Debug, Clone)]
258pub struct URLPathVersioning {
259 /// The fallback version when no version is found in the URL path.
260 pub default_version: Option<String>,
261 /// The set of allowed API versions.
262 pub allowed_versions: HashSet<String>,
263 /// The parameter name for version (default: `"version"`).
264 pub version_param: String,
265 /// The regex pattern used to extract the version from the URL path.
266 pub path_regex: Regex,
267}
268
269impl URLPathVersioning {
270 /// Create a new URLPathVersioning instance
271 ///
272 /// # Examples
273 ///
274 /// ```
275 /// use reinhardt_rest::versioning::{URLPathVersioning, BaseVersioning};
276 ///
277 /// let versioning = URLPathVersioning::new();
278 /// assert_eq!(versioning.default_version.as_deref(), None);
279 /// ```
280 pub fn new() -> Self {
281 Self {
282 default_version: None,
283 allowed_versions: HashSet::new(),
284 version_param: "version".to_string(),
285 path_regex: Regex::new(r"/v(\d+\.?\d*)(?:/|$)").unwrap(),
286 }
287 }
288 /// Set the default version to use when no version is found in the path
289 ///
290 /// # Examples
291 ///
292 /// ```
293 /// use reinhardt_rest::versioning::{URLPathVersioning, BaseVersioning};
294 ///
295 /// let versioning = URLPathVersioning::new()
296 /// .with_default_version("1.0");
297 /// assert_eq!(versioning.default_version.as_deref(), Some("1.0"));
298 /// ```
299 pub fn with_default_version(mut self, version: impl Into<String>) -> Self {
300 self.default_version = Some(version.into());
301 self
302 }
303 /// Set the allowed versions
304 ///
305 /// # Examples
306 ///
307 /// ```
308 /// use reinhardt_rest::versioning::{URLPathVersioning, BaseVersioning};
309 ///
310 /// let versioning = URLPathVersioning::new()
311 /// .with_allowed_versions(vec!["1", "2", "3"]);
312 /// assert!(versioning.is_allowed_version("1"));
313 /// assert!(!versioning.is_allowed_version("99"));
314 /// ```
315 pub fn with_allowed_versions(mut self, versions: Vec<impl Into<String>>) -> Self {
316 self.allowed_versions = versions.into_iter().map(|v| v.into()).collect();
317 self
318 }
319 /// Set the version parameter name (for trait compatibility)
320 ///
321 /// # Examples
322 ///
323 /// ```
324 /// use reinhardt_rest::versioning::{URLPathVersioning, BaseVersioning};
325 ///
326 /// let versioning = URLPathVersioning::new()
327 /// .with_version_param("v");
328 /// assert_eq!(versioning.version_param.as_str(), "v");
329 /// ```
330 pub fn with_version_param(mut self, param: impl Into<String>) -> Self {
331 self.version_param = param.into();
332 self
333 }
334 /// Set a custom regex pattern for extracting version from path
335 ///
336 /// # Examples
337 ///
338 /// ```
339 /// use reinhardt_rest::versioning::URLPathVersioning;
340 /// use regex::Regex;
341 ///
342 /// let custom_regex = Regex::new(r"/api/v(\d+)").unwrap();
343 /// let versioning = URLPathVersioning::new()
344 /// .with_path_regex(custom_regex);
345 /// // The versioning will now match paths like /api/v1, /api/v2, etc.
346 /// ```
347 pub fn with_path_regex(mut self, regex: Regex) -> Self {
348 self.path_regex = regex;
349 self
350 }
351
352 /// Set a custom pattern for extracting version from path (for configuration compatibility)
353 ///
354 /// This converts a pattern like "/v{version}/" into a regex pattern.
355 ///
356 /// # Examples
357 ///
358 /// ```
359 /// use reinhardt_rest::versioning::URLPathVersioning;
360 ///
361 /// let versioning = URLPathVersioning::new()
362 /// .with_pattern("/v{version}/");
363 /// // The versioning will now match paths like /v1/, /v2/, etc.
364 /// ```
365 pub fn with_pattern(mut self, pattern: &str) -> Self {
366 // Convert pattern like "/v{version}/" to regex "/v?([^/]+)"
367 let regex_pattern = pattern.replace("{version}", "([^/]+)");
368 if let Ok(regex) = Regex::new(®ex_pattern) {
369 self.path_regex = regex;
370 }
371 self
372 }
373}
374
375impl Default for URLPathVersioning {
376 fn default() -> Self {
377 Self::new()
378 }
379}
380
381#[async_trait]
382impl BaseVersioning for URLPathVersioning {
383 async fn determine_version(&self, request: &Request) -> Result<String> {
384 let path = request.uri.path();
385
386 // Try to extract version from path using regex
387 if let Some(captures) = self.path_regex.captures(path)
388 && let Some(version_match) = captures.get(1)
389 {
390 let version = version_match.as_str();
391 if self.is_allowed_version(version) {
392 return Ok(version.to_owned());
393 } else {
394 // Avoid intermediate String allocation from VersionNotAllowed(String).to_string()
395 return Err(Error::Validation(format!("Version not allowed: {version}")));
396 }
397 }
398
399 // Return default version if no version in path.
400 // Skip cloning the Option<String> wrapper; final String alloc is unavoidable.
401 Ok(self.default_version.as_deref().unwrap_or("1.0").to_owned())
402 }
403
404 fn default_version(&self) -> Option<&str> {
405 self.default_version.as_deref()
406 }
407
408 fn allowed_versions(&self) -> Option<&HashSet<String>> {
409 Some(&self.allowed_versions)
410 }
411
412 fn version_param(&self) -> &str {
413 &self.version_param
414 }
415}
416
417/// Hostname versioning
418///
419/// Example: `v1.api.example.com` or `api-v2.example.com`
420#[derive(Debug, Clone)]
421pub struct HostNameVersioning {
422 /// The fallback version when no version is found in the hostname.
423 pub default_version: Option<String>,
424 /// The set of allowed API versions.
425 pub allowed_versions: HashSet<String>,
426 /// The regex pattern used to extract the version from the hostname.
427 pub hostname_regex: Regex,
428 /// Maps specific hostnames to their API versions.
429 /// Takes precedence over regex extraction.
430 pub hostname_to_version: HashMap<String, String>,
431}
432
433impl HostNameVersioning {
434 /// Create a new HostNameVersioning instance
435 ///
436 /// # Examples
437 ///
438 /// ```
439 /// use reinhardt_rest::versioning::HostNameVersioning;
440 ///
441 /// let versioning = HostNameVersioning::new();
442 /// assert_eq!(versioning.default_version.as_deref(), None);
443 /// ```
444 pub fn new() -> Self {
445 Self {
446 default_version: None,
447 allowed_versions: HashSet::new(),
448 hostname_regex: Regex::new(r"^([a-zA-Z0-9]+)\.").unwrap(),
449 hostname_to_version: HashMap::new(),
450 }
451 }
452 /// Set the default version to use when no version is found in hostname
453 ///
454 /// # Examples
455 ///
456 /// ```
457 /// use reinhardt_rest::versioning::HostNameVersioning;
458 ///
459 /// let versioning = HostNameVersioning::new()
460 /// .with_default_version("1.0");
461 /// assert_eq!(versioning.default_version.as_deref(), Some("1.0"));
462 /// ```
463 pub fn with_default_version(mut self, version: impl Into<String>) -> Self {
464 self.default_version = Some(version.into());
465 self
466 }
467 /// Set the allowed versions
468 ///
469 /// # Examples
470 ///
471 /// ```
472 /// use reinhardt_rest::versioning::{HostNameVersioning, BaseVersioning};
473 ///
474 /// let versioning = HostNameVersioning::new()
475 /// .with_allowed_versions(vec!["v1", "v2", "v3"]);
476 /// assert!(versioning.is_allowed_version("v1"));
477 /// assert!(!versioning.is_allowed_version("v99"));
478 /// ```
479 pub fn with_allowed_versions(mut self, versions: Vec<impl Into<String>>) -> Self {
480 self.allowed_versions = versions.into_iter().map(|v| v.into()).collect();
481 self
482 }
483 /// Set a custom regex pattern for extracting version from hostname
484 ///
485 /// # Examples
486 ///
487 /// ```
488 /// use reinhardt_rest::versioning::HostNameVersioning;
489 /// use regex::Regex;
490 ///
491 /// let custom_regex = Regex::new(r"^v(\d+)-api\.").unwrap();
492 /// let versioning = HostNameVersioning::new()
493 /// .with_hostname_regex(custom_regex);
494 /// // The versioning will now match hostnames like v1-api.example.com
495 /// ```
496 pub fn with_hostname_regex(mut self, regex: Regex) -> Self {
497 self.hostname_regex = regex;
498 self
499 }
500
501 /// Set a host format pattern (for configuration compatibility)
502 ///
503 /// This converts a host format like "{version}.api.example.com" into a regex pattern.
504 ///
505 /// # Examples
506 ///
507 /// ```
508 /// use reinhardt_rest::versioning::HostNameVersioning;
509 ///
510 /// let versioning = HostNameVersioning::new()
511 /// .with_host_format("{version}.api.example.com");
512 /// // The versioning will match hostnames like v1.api.example.com
513 /// ```
514 pub fn with_host_format(mut self, format: &str) -> Self {
515 // Convert format like "{version}.api.example.com" to regex "^([^.]+)\.api\.example\.com"
516 // Escape dots first, then replace placeholder to prevent regex corruption
517 const PLACEHOLDER: &str = "__REINHARDT_VERSION_PLACEHOLDER__";
518 let pattern = format.replace("{version}", PLACEHOLDER);
519 let pattern = pattern.replace(".", "\\.");
520 let pattern = pattern.replace(PLACEHOLDER, "([^.]+)");
521 let pattern = format!("^{}", pattern);
522 if let Ok(regex) = Regex::new(&pattern) {
523 self.hostname_regex = regex;
524 }
525 self
526 }
527
528 /// Set hostname patterns for version mapping (for configuration compatibility)
529 ///
530 /// This allows mapping specific hostnames to their API versions.
531 /// The hostname mapping takes precedence over regex extraction when determining version.
532 ///
533 /// # Examples
534 ///
535 /// ```
536 /// use reinhardt_rest::versioning::HostNameVersioning;
537 ///
538 /// let versioning = HostNameVersioning::new()
539 /// .with_hostname_pattern("v1", "v1.api.example.com")
540 /// .with_hostname_pattern("v2", "v2.api.example.com");
541 /// // Request to v1.api.example.com will resolve to version "v1"
542 /// // Request to v2.api.example.com will resolve to version "v2"
543 /// ```
544 pub fn with_hostname_pattern(mut self, version: &str, hostname: &str) -> Self {
545 self.allowed_versions.insert(version.to_string());
546 self.hostname_to_version
547 .insert(hostname.to_string(), version.to_string());
548 self
549 }
550}
551
552impl Default for HostNameVersioning {
553 fn default() -> Self {
554 Self::new()
555 }
556}
557
558#[async_trait]
559impl BaseVersioning for HostNameVersioning {
560 async fn determine_version(&self, request: &Request) -> Result<String> {
561 // Extract hostname from request
562 if let Some(host) = request.headers.get("host") {
563 let host_str = host
564 .to_str()
565 .map_err(|_| Error::Validation(VersioningError::InvalidHostname.to_string()))?;
566
567 // Remove port if present
568 let hostname = host_str.split(':').next().unwrap_or(host_str);
569
570 // Priority 1: Check explicit hostname→version mapping
571 if let Some(version) = self.hostname_to_version.get(hostname)
572 && self.is_allowed_version(version)
573 {
574 return Ok(version.clone());
575 }
576
577 // Priority 2: Try to extract version from hostname using regex
578 if let Some(captures) = self.hostname_regex.captures(hostname)
579 && let Some(version_match) = captures.get(1)
580 {
581 let version = version_match.as_str();
582 if self.is_allowed_version(version) {
583 return Ok(version.to_string());
584 }
585 }
586 }
587
588 // Return default version if no version in hostname.
589 // Skip cloning the Option<String> wrapper; final String alloc is unavoidable.
590 Ok(self.default_version.as_deref().unwrap_or("1.0").to_owned())
591 }
592
593 fn default_version(&self) -> Option<&str> {
594 self.default_version.as_deref()
595 }
596
597 fn allowed_versions(&self) -> Option<&HashSet<String>> {
598 Some(&self.allowed_versions)
599 }
600}
601
602/// Query parameter versioning
603///
604/// Example: `/users/?version=1.0` or `/users/?v=2.0`
605#[derive(Debug, Clone)]
606pub struct QueryParameterVersioning {
607 /// The fallback version when no version query parameter is present.
608 pub default_version: Option<String>,
609 /// The set of allowed API versions.
610 pub allowed_versions: HashSet<String>,
611 /// The query parameter name for the version (default: `"version"`).
612 pub version_param: String,
613}
614
615impl QueryParameterVersioning {
616 /// Create a new QueryParameterVersioning instance
617 ///
618 /// # Examples
619 ///
620 /// ```
621 /// use reinhardt_rest::versioning::QueryParameterVersioning;
622 ///
623 /// let versioning = QueryParameterVersioning::new();
624 /// assert_eq!(versioning.default_version.as_deref(), None);
625 /// assert_eq!(versioning.version_param.as_str(), "version");
626 /// ```
627 pub fn new() -> Self {
628 Self {
629 default_version: None,
630 allowed_versions: HashSet::new(),
631 version_param: "version".to_string(),
632 }
633 }
634 /// Set the default version to use when no version is in query parameters
635 ///
636 /// # Examples
637 ///
638 /// ```
639 /// use reinhardt_rest::versioning::QueryParameterVersioning;
640 ///
641 /// let versioning = QueryParameterVersioning::new()
642 /// .with_default_version("1.0");
643 /// assert_eq!(versioning.default_version.as_deref(), Some("1.0"));
644 /// ```
645 pub fn with_default_version(mut self, version: impl Into<String>) -> Self {
646 self.default_version = Some(version.into());
647 self
648 }
649 /// Set the allowed versions
650 ///
651 /// # Examples
652 ///
653 /// ```
654 /// use reinhardt_rest::versioning::{QueryParameterVersioning, BaseVersioning};
655 ///
656 /// let versioning = QueryParameterVersioning::new()
657 /// .with_allowed_versions(vec!["1.0", "2.0", "3.0"]);
658 /// assert!(versioning.is_allowed_version("1.0"));
659 /// assert!(!versioning.is_allowed_version("4.0"));
660 /// ```
661 pub fn with_allowed_versions(mut self, versions: Vec<impl Into<String>>) -> Self {
662 self.allowed_versions = versions.into_iter().map(|v| v.into()).collect();
663 self
664 }
665 /// Set the query parameter name to use for version detection
666 ///
667 /// # Examples
668 ///
669 /// ```
670 /// use reinhardt_rest::versioning::QueryParameterVersioning;
671 ///
672 /// let versioning = QueryParameterVersioning::new()
673 /// .with_version_param("v");
674 /// assert_eq!(versioning.version_param.as_str(), "v");
675 /// // This will now look for ?v=1.0 instead of ?version=1.0
676 /// ```
677 pub fn with_version_param(mut self, param: impl Into<String>) -> Self {
678 self.version_param = param.into();
679 self
680 }
681}
682
683impl Default for QueryParameterVersioning {
684 fn default() -> Self {
685 Self::new()
686 }
687}
688
689#[async_trait]
690impl BaseVersioning for QueryParameterVersioning {
691 async fn determine_version(&self, request: &Request) -> Result<String> {
692 // Parse query string for version parameter
693 if let Some(query) = request.uri.query() {
694 for param in query.split('&') {
695 if let Some((key, value)) = param.split_once('=')
696 && key == self.version_param
697 {
698 if self.is_allowed_version(value) {
699 return Ok(value.to_owned());
700 } else {
701 // Avoid intermediate String allocation from VersionNotAllowed(String).to_string()
702 return Err(Error::Validation(format!("Version not allowed: {value}")));
703 }
704 }
705 }
706 }
707
708 // Return default version if no version in query.
709 // Skip cloning the Option<String> wrapper; final String alloc is unavoidable.
710 Ok(self.default_version.as_deref().unwrap_or("1.0").to_owned())
711 }
712
713 fn default_version(&self) -> Option<&str> {
714 self.default_version.as_deref()
715 }
716
717 fn allowed_versions(&self) -> Option<&HashSet<String>> {
718 Some(&self.allowed_versions)
719 }
720
721 fn version_param(&self) -> &str {
722 &self.version_param
723 }
724}
725
726/// Namespace versioning (URL namespace-based)
727///
728/// Extracts version from URL namespace patterns (e.g., /v1/, /v2/)
729/// Now fully implemented with router namespace support
730#[derive(Debug)]
731pub struct NamespaceVersioning {
732 /// The fallback version when no version is found in the namespace.
733 pub default_version: Option<String>,
734 /// The set of allowed API versions.
735 pub allowed_versions: HashSet<String>,
736 /// Pattern for extracting version from namespace (e.g., "/v{version}/")
737 pub pattern: String,
738 /// Namespace prefix (e.g., "api")
739 pub namespace_prefix: Option<String>,
740 /// Cached compiled regex for version extraction
741 compiled_regex: OnceLock<Option<Regex>>,
742}
743
744impl Clone for NamespaceVersioning {
745 fn clone(&self) -> Self {
746 Self {
747 default_version: self.default_version.clone(),
748 allowed_versions: self.allowed_versions.clone(),
749 pattern: self.pattern.clone(),
750 namespace_prefix: self.namespace_prefix.clone(),
751 // Reset compiled_regex so it will be recompiled on first use
752 compiled_regex: OnceLock::new(),
753 }
754 }
755}
756
757impl NamespaceVersioning {
758 /// Create a new NamespaceVersioning instance
759 ///
760 /// # Examples
761 ///
762 /// ```
763 /// use reinhardt_rest::versioning::NamespaceVersioning;
764 ///
765 /// let versioning = NamespaceVersioning::new();
766 /// assert_eq!(versioning.default_version.as_deref(), None);
767 /// assert_eq!(versioning.pattern, "/v{version}/");
768 /// ```
769 pub fn new() -> Self {
770 Self {
771 default_version: None,
772 allowed_versions: HashSet::new(),
773 pattern: "/v{version}/".to_string(),
774 namespace_prefix: None,
775 compiled_regex: OnceLock::new(),
776 }
777 }
778 /// Set the default version to use when no version is found in namespace
779 ///
780 /// # Examples
781 ///
782 /// ```
783 /// use reinhardt_rest::versioning::NamespaceVersioning;
784 ///
785 /// let versioning = NamespaceVersioning::new()
786 /// .with_default_version("1.0");
787 /// assert_eq!(versioning.default_version.as_deref(), Some("1.0"));
788 /// ```
789 pub fn with_default_version(mut self, version: impl Into<String>) -> Self {
790 self.default_version = Some(version.into());
791 self
792 }
793 /// Set the allowed versions
794 ///
795 /// # Examples
796 ///
797 /// ```
798 /// use reinhardt_rest::versioning::{NamespaceVersioning, BaseVersioning};
799 ///
800 /// let versioning = NamespaceVersioning::new()
801 /// .with_allowed_versions(vec!["1", "1.0", "2", "2.0"]);
802 /// assert!(versioning.is_allowed_version("1"));
803 /// assert!(versioning.is_allowed_version("2.0"));
804 /// assert!(!versioning.is_allowed_version("99"));
805 /// ```
806 pub fn with_allowed_versions(mut self, versions: Vec<impl Into<String>>) -> Self {
807 self.allowed_versions = versions.into_iter().map(|v| v.into()).collect();
808 self
809 }
810
811 /// Set the namespace prefix (e.g., "api")
812 ///
813 /// This prefix is used when constructing full namespace patterns for version detection.
814 ///
815 /// # Examples
816 ///
817 /// ```
818 /// use reinhardt_rest::versioning::NamespaceVersioning;
819 ///
820 /// let versioning = NamespaceVersioning::new()
821 /// .with_namespace_prefix("api");
822 /// assert_eq!(versioning.namespace_prefix, Some("api".to_string()));
823 /// ```
824 pub fn with_namespace_prefix(mut self, prefix: &str) -> Self {
825 self.namespace_prefix = Some(prefix.to_string());
826 self
827 }
828
829 /// Set a custom pattern for extracting version from namespace
830 ///
831 /// This converts a pattern like "/v{version}/" into a regex pattern for matching
832 /// namespaces like /v1/, /v2/, etc.
833 ///
834 /// # Examples
835 ///
836 /// ```
837 /// use reinhardt_rest::versioning::NamespaceVersioning;
838 ///
839 /// let versioning = NamespaceVersioning::new()
840 /// .with_pattern("/api/v{version}/");
841 /// assert_eq!(versioning.pattern, "/api/v{version}/");
842 /// ```
843 pub fn with_pattern(mut self, pattern: &str) -> Self {
844 self.pattern = pattern.to_string();
845 // Reset cached regex since the pattern changed
846 self.compiled_regex = OnceLock::new();
847 self
848 }
849}
850
851impl Default for NamespaceVersioning {
852 fn default() -> Self {
853 Self::new()
854 }
855}
856
857#[async_trait]
858impl BaseVersioning for NamespaceVersioning {
859 async fn determine_version(&self, request: &Request) -> Result<String> {
860 let path = request.uri.path();
861
862 // Use the configured pattern to extract version
863 if let Some(version) = self.extract_version_from_path(path)
864 && self.is_allowed_version(&version)
865 {
866 return Ok(version);
867 }
868
869 // Fallback to default version.
870 // Skip cloning the Option<String> wrapper; final String alloc is unavoidable.
871 Ok(self.default_version.as_deref().unwrap_or("1.0").to_owned())
872 }
873
874 fn default_version(&self) -> Option<&str> {
875 self.default_version.as_deref()
876 }
877
878 fn allowed_versions(&self) -> Option<&HashSet<String>> {
879 Some(&self.allowed_versions)
880 }
881}
882
883impl NamespaceVersioning {
884 /// Get or compile the regex for version extraction from the configured pattern
885 fn get_compiled_regex(&self) -> Option<&Regex> {
886 self.compiled_regex
887 .get_or_init(|| {
888 let regex_pattern = self
889 .pattern
890 .replace("{version}", r"([^/]+)")
891 .replace("/", r"\/");
892 let full_pattern = format!("^{}", regex_pattern);
893 regex::Regex::new(&full_pattern).ok()
894 })
895 .as_ref()
896 }
897
898 /// Extract version from a path using the configured pattern
899 fn extract_version_from_path(&self, path: &str) -> Option<String> {
900 if let Some(regex) = self.get_compiled_regex()
901 && let Some(captures) = regex.captures(path)
902 && let Some(version_match) = captures.get(1)
903 {
904 return Some(version_match.as_str().to_string());
905 }
906 None
907 }
908
909 /// Check if a version is allowed
910 fn is_allowed_version(&self, version: &str) -> bool {
911 self.allowed_versions.is_empty() || self.allowed_versions.contains(version)
912 }
913
914 /// Extract a version from a router-aware path, applying the
915 /// configured pattern.
916 ///
917 /// Unlike `extract_version_from_path` (private helper), this method is
918 /// router-aware: it returns `Some(version)` only if `path` matches
919 /// (starts with) at least one `path_prefix` registered on the
920 /// router AND the configured pattern successfully extracts a
921 /// version from that prefix. Otherwise it returns `None`. This
922 /// prevents reporting a version for paths that no route on
923 /// `router` actually serves.
924 ///
925 /// The trait bound on [`reinhardt_router::VersionedRouter`] is what
926 /// finally lets this method live in `reinhardt-rest` without
927 /// pulling in `reinhardt-urls` (issue #4321).
928 ///
929 /// # Examples
930 ///
931 /// ```
932 /// use reinhardt_rest::versioning::NamespaceVersioning;
933 /// use reinhardt_router::{RouteVersionInfo, VersionedRouter};
934 ///
935 /// struct FakeRouter;
936 /// impl VersionedRouter for FakeRouter {
937 /// fn route_version_infos(&self) -> Vec<RouteVersionInfo> {
938 /// vec![RouteVersionInfo::new(Some("v1".into()), "/v1/")]
939 /// }
940 /// }
941 ///
942 /// let versioning = NamespaceVersioning::new()
943 /// .with_pattern("/v{version}/")
944 /// .with_allowed_versions(vec!["1", "2"]);
945 ///
946 /// let router = FakeRouter;
947 /// // "/v1/users/" matches the "/v1/" prefix registered on the router.
948 /// let version = versioning.extract_version_from_router(&router, "/v1/users/");
949 /// assert_eq!(version, Some("1".to_string()));
950 ///
951 /// // "/v9/users/" does NOT match any registered prefix → None.
952 /// let unknown = versioning.extract_version_from_router(&router, "/v9/users/");
953 /// assert_eq!(unknown, None);
954 /// ```
955 pub fn extract_version_from_router<R: reinhardt_router::VersionedRouter + ?Sized>(
956 &self,
957 router: &R,
958 path: &str,
959 ) -> Option<String> {
960 // Find the first registered route whose `path_prefix` matches
961 // the incoming `path`, then extract the version from that
962 // prefix. If no route matches, the path is not served by this
963 // router and we return None.
964 router
965 .route_version_infos()
966 .into_iter()
967 .find(|info| path.starts_with(&info.path_prefix))
968 .and_then(|info| self.extract_version_from_path(&info.path_prefix))
969 }
970
971 /// Enumerate the versions currently registered on `router`.
972 ///
973 /// The router exposes its routes through
974 /// [`reinhardt_router::VersionedRouter`]; this method then applies
975 /// the configured pattern to each route's `path_prefix` and filters
976 /// by `allowed_versions` (when configured).
977 ///
978 /// # Ordering
979 ///
980 /// The returned `Vec<String>` is sorted **ascending in
981 /// lexicographic (string) order** and deduplicated. Lexicographic
982 /// order coincides with numeric order for single-digit versions
983 /// (e.g. `"1" < "2"`) but diverges for multi-digit versions
984 /// (e.g. `"10"` sorts before `"2"`). Callers that need a different
985 /// ordering must re-sort the result themselves.
986 ///
987 /// # Examples
988 ///
989 /// ```
990 /// use reinhardt_rest::versioning::NamespaceVersioning;
991 /// use reinhardt_router::{RouteVersionInfo, VersionedRouter};
992 ///
993 /// struct FakeRouter;
994 /// impl VersionedRouter for FakeRouter {
995 /// fn route_version_infos(&self) -> Vec<RouteVersionInfo> {
996 /// vec![
997 /// RouteVersionInfo::new(Some("v1".into()), "/v1/users/"),
998 /// RouteVersionInfo::new(Some("v2".into()), "/v2/users/"),
999 /// ]
1000 /// }
1001 /// }
1002 ///
1003 /// let versioning = NamespaceVersioning::new().with_pattern("/v{version}/");
1004 /// let router = FakeRouter;
1005 ///
1006 /// let versions = versioning.get_available_versions_from_router(&router);
1007 /// assert!(versions.contains(&"1".to_string()));
1008 /// assert!(versions.contains(&"2".to_string()));
1009 /// ```
1010 pub fn get_available_versions_from_router<R: reinhardt_router::VersionedRouter + ?Sized>(
1011 &self,
1012 router: &R,
1013 ) -> Vec<String> {
1014 let mut versions: Vec<String> = router
1015 .route_version_infos()
1016 .into_iter()
1017 .filter_map(|info| self.extract_version_from_path(&info.path_prefix))
1018 .filter(|version| self.is_allowed_version(version))
1019 .collect();
1020 versions.sort();
1021 versions.dedup();
1022 versions
1023 }
1024}
1025
1026#[cfg(test)]
1027pub mod test_utils {
1028 use bytes::Bytes;
1029 use hyper::header::HeaderName;
1030 use hyper::{HeaderMap, Method, Uri, Version};
1031 use reinhardt_http::Request;
1032
1033 pub fn create_test_request(uri: &str, headers: Vec<(String, String)>) -> Request {
1034 let uri = uri.parse::<Uri>().unwrap();
1035 let mut header_map = HeaderMap::new();
1036 for (key, value) in headers {
1037 let header_name: HeaderName = key.parse().unwrap();
1038 header_map.insert(header_name, value.parse().unwrap());
1039 }
1040
1041 Request::builder()
1042 .method(Method::GET)
1043 .uri(uri)
1044 .version(Version::HTTP_11)
1045 .headers(header_map)
1046 .body(Bytes::new())
1047 .build()
1048 .unwrap()
1049 }
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055 use test_utils::create_test_request;
1056
1057 #[tokio::test]
1058 async fn test_accept_header_versioning() {
1059 let versioning = AcceptHeaderVersioning::new()
1060 .with_default_version("1.0")
1061 .with_allowed_versions(vec!["1.0", "2.0"]);
1062
1063 // Test with version in Accept header
1064 let request = create_test_request(
1065 "/users/",
1066 vec![(
1067 "accept".to_string(),
1068 "application/json; version=2.0".to_string(),
1069 )],
1070 );
1071 let version = versioning.determine_version(&request).await.unwrap();
1072 assert_eq!(version, "2.0");
1073
1074 // Test without version (should return default)
1075 let request = create_test_request(
1076 "/users/",
1077 vec![("accept".to_string(), "application/json".to_string())],
1078 );
1079 let version = versioning.determine_version(&request).await.unwrap();
1080 assert_eq!(version, "1.0");
1081 }
1082
1083 #[tokio::test]
1084 async fn test_url_path_versioning() {
1085 let versioning = URLPathVersioning::new()
1086 .with_default_version("1.0")
1087 .with_allowed_versions(vec!["1.0", "2.0", "2"]);
1088
1089 // Test with version in path
1090 let request = create_test_request("/v2/users/", vec![]);
1091 let version = versioning.determine_version(&request).await.unwrap();
1092 assert_eq!(version, "2");
1093
1094 // Test without version (should return default)
1095 let request = create_test_request("/users/", vec![]);
1096 let version = versioning.determine_version(&request).await.unwrap();
1097 assert_eq!(version, "1.0");
1098 }
1099
1100 #[tokio::test]
1101 async fn test_hostname_versioning() {
1102 let versioning = HostNameVersioning::new()
1103 .with_default_version("1.0")
1104 .with_allowed_versions(vec!["v1", "v2"]);
1105
1106 // Test with version in hostname
1107 let request = create_test_request(
1108 "/users/",
1109 vec![("host".to_string(), "v2.api.example.com".to_string())],
1110 );
1111 let version = versioning.determine_version(&request).await.unwrap();
1112 assert_eq!(version, "v2");
1113
1114 // Test without version (should return default)
1115 let request = create_test_request(
1116 "/users/",
1117 vec![("host".to_string(), "api.example.com".to_string())],
1118 );
1119 let version = versioning.determine_version(&request).await.unwrap();
1120 assert_eq!(version, "1.0");
1121 }
1122
1123 #[tokio::test]
1124 async fn test_query_parameter_versioning() {
1125 let versioning = QueryParameterVersioning::new()
1126 .with_default_version("1.0")
1127 .with_allowed_versions(vec!["1.0", "2.0"]);
1128
1129 // Test with version in query parameter
1130 let request = create_test_request("/users/?version=2.0", vec![]);
1131 let version = versioning.determine_version(&request).await.unwrap();
1132 assert_eq!(version, "2.0");
1133
1134 // Test without version (should return default)
1135 let request = create_test_request("/users/", vec![]);
1136 let version = versioning.determine_version(&request).await.unwrap();
1137 assert_eq!(version, "1.0");
1138 }
1139
1140 #[tokio::test]
1141 async fn test_namespace_versioning() {
1142 let versioning = NamespaceVersioning::new()
1143 .with_default_version("1.0")
1144 .with_allowed_versions(vec!["1", "1.0", "2", "2.0", "3.0"]);
1145
1146 // Test with version in namespace (v1 format)
1147 let request = create_test_request("/v1/users/", vec![]);
1148 let version = versioning.determine_version(&request).await.unwrap();
1149 assert_eq!(version, "1");
1150
1151 // Test with version in namespace (v2.0 format)
1152 let request = create_test_request("/v2.0/users/", vec![]);
1153 let version = versioning.determine_version(&request).await.unwrap();
1154 assert_eq!(version, "2.0");
1155
1156 // Test without version (should return default)
1157 let request = create_test_request("/users/", vec![]);
1158 let version = versioning.determine_version(&request).await.unwrap();
1159 assert_eq!(version, "1.0");
1160
1161 // Test with non-version namespace
1162 let request = create_test_request("/api/users/", vec![]);
1163 let version = versioning.determine_version(&request).await.unwrap();
1164 assert_eq!(version, "1.0");
1165 }
1166
1167 #[tokio::test]
1168 async fn test_namespace_versioning_with_custom_pattern() {
1169 let versioning = NamespaceVersioning::new()
1170 .with_default_version("1.0")
1171 .with_pattern("/api/v{version}/")
1172 .with_allowed_versions(vec!["1", "2"]);
1173
1174 // Test with custom pattern
1175 let request = create_test_request("/api/v1/users/", vec![]);
1176 let version = versioning.determine_version(&request).await.unwrap();
1177 assert_eq!(version, "1");
1178
1179 // Test with different version
1180 let request = create_test_request("/api/v2/users/", vec![]);
1181 let version = versioning.determine_version(&request).await.unwrap();
1182 assert_eq!(version, "2");
1183
1184 // Test with old pattern (should not match)
1185 let request = create_test_request("/v1/users/", vec![]);
1186 let version = versioning.determine_version(&request).await.unwrap();
1187 assert_eq!(version, "1.0"); // Falls back to default
1188 }
1189
1190 #[tokio::test]
1191 async fn test_hostname_versioning_with_host_format_dots_not_corrupted() {
1192 // Arrange - format with dots that would be corrupted by the old implementation
1193 let versioning = HostNameVersioning::new()
1194 .with_host_format("{version}.api.v2.example.com")
1195 .with_allowed_versions(vec!["v1", "v3"]);
1196
1197 // Act
1198 let request = create_test_request(
1199 "/users/",
1200 vec![("host".to_string(), "v1.api.v2.example.com".to_string())],
1201 );
1202 let version = versioning.determine_version(&request).await.unwrap();
1203
1204 // Assert
1205 assert_eq!(version, "v1");
1206
1207 // Act - different version
1208 let request = create_test_request(
1209 "/users/",
1210 vec![("host".to_string(), "v3.api.v2.example.com".to_string())],
1211 );
1212 let version = versioning.determine_version(&request).await.unwrap();
1213
1214 // Assert
1215 assert_eq!(version, "v3");
1216 }
1217
1218 // Note: Router integration test removed to avoid circular dependency with reinhardt-urls.
1219 // Router integration tests should be placed in /tests/integration crate.
1220}