rustapi_openapi/versioning/
strategy.rs1use super::version::ApiVersion;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub enum VersionStrategy {
12 Path {
17 pattern: String,
19 },
20
21 Header {
25 name: String,
27 },
28
29 Query {
33 param: String,
35 },
36
37 Accept {
41 pattern: String,
44 },
45
46 Custom {
50 name: String,
52 },
53}
54
55impl VersionStrategy {
56 pub fn path() -> Self {
60 Self::Path {
61 pattern: "/v{version}/".to_string(),
62 }
63 }
64
65 pub fn path_with_pattern(pattern: impl Into<String>) -> Self {
67 Self::Path {
68 pattern: pattern.into(),
69 }
70 }
71
72 pub fn header() -> Self {
76 Self::Header {
77 name: "X-API-Version".to_string(),
78 }
79 }
80
81 pub fn header_with_name(name: impl Into<String>) -> Self {
83 Self::Header { name: name.into() }
84 }
85
86 pub fn query() -> Self {
90 Self::Query {
91 param: "version".to_string(),
92 }
93 }
94
95 pub fn query_with_param(param: impl Into<String>) -> Self {
97 Self::Query {
98 param: param.into(),
99 }
100 }
101
102 pub fn accept() -> Self {
106 Self::Accept {
107 pattern: "application/vnd.api.v{version}+json".to_string(),
108 }
109 }
110
111 pub fn accept_with_pattern(pattern: impl Into<String>) -> Self {
113 Self::Accept {
114 pattern: pattern.into(),
115 }
116 }
117
118 pub fn custom(name: impl Into<String>) -> Self {
120 Self::Custom { name: name.into() }
121 }
122}
123
124impl Default for VersionStrategy {
125 fn default() -> Self {
126 Self::path()
127 }
128}
129
130#[derive(Debug, Clone)]
132pub struct VersionExtractor {
133 strategies: Vec<VersionStrategy>,
135 default: ApiVersion,
137}
138
139impl VersionExtractor {
140 pub fn new() -> Self {
142 Self {
143 strategies: vec![VersionStrategy::path()],
144 default: ApiVersion::v1(),
145 }
146 }
147
148 pub fn with_strategy(strategy: VersionStrategy) -> Self {
150 Self {
151 strategies: vec![strategy],
152 default: ApiVersion::v1(),
153 }
154 }
155
156 pub fn with_strategies(strategies: Vec<VersionStrategy>) -> Self {
158 Self {
159 strategies,
160 default: ApiVersion::v1(),
161 }
162 }
163
164 pub fn default_version(mut self, version: ApiVersion) -> Self {
166 self.default = version;
167 self
168 }
169
170 pub fn add_strategy(mut self, strategy: VersionStrategy) -> Self {
172 self.strategies.push(strategy);
173 self
174 }
175
176 pub fn extract_from_path(&self, path: &str) -> Option<ApiVersion> {
178 for strategy in &self.strategies {
179 if let VersionStrategy::Path { pattern } = strategy {
180 if let Some(version) = Self::extract_path_version(path, pattern) {
181 return Some(version);
182 }
183 }
184 }
185 None
186 }
187
188 pub fn extract_from_headers(&self, headers: &HashMap<String, String>) -> Option<ApiVersion> {
190 for strategy in &self.strategies {
191 match strategy {
192 VersionStrategy::Header { name } => {
193 if let Some(value) = headers.get(&name.to_lowercase()) {
194 if let Ok(version) = value.parse() {
195 return Some(version);
196 }
197 }
198 }
199 VersionStrategy::Accept { pattern } => {
200 if let Some(accept) = headers.get("accept") {
201 if let Some(version) = Self::extract_accept_version(accept, pattern) {
202 return Some(version);
203 }
204 }
205 }
206 _ => {}
207 }
208 }
209 None
210 }
211
212 pub fn extract_from_query(&self, query: &str) -> Option<ApiVersion> {
214 let params: HashMap<_, _> = query
215 .split('&')
216 .filter_map(|pair| {
217 let mut parts = pair.splitn(2, '=');
218 Some((parts.next()?.to_string(), parts.next()?.to_string()))
219 })
220 .collect();
221
222 for strategy in &self.strategies {
223 if let VersionStrategy::Query { param } = strategy {
224 if let Some(value) = params.get(param) {
225 if let Ok(version) = value.parse() {
226 return Some(version);
227 }
228 }
229 }
230 }
231 None
232 }
233
234 pub fn get_default(&self) -> ApiVersion {
236 self.default
237 }
238
239 fn extract_path_version(path: &str, pattern: &str) -> Option<ApiVersion> {
241 let before = pattern.split("{version}").next()?;
243 let after = pattern.split("{version}").nth(1)?;
244
245 if let Some(start) = path.find(before) {
247 let version_start = start + before.len();
248 let remaining = &path[version_start..];
249
250 let version_end = if after.is_empty() {
252 remaining.len()
253 } else {
254 remaining.find(after).unwrap_or(remaining.len())
255 };
256
257 let version_str = &remaining[..version_end];
258 version_str.parse().ok()
259 } else {
260 None
261 }
262 }
263
264 fn extract_accept_version(accept: &str, pattern: &str) -> Option<ApiVersion> {
266 let before = pattern.split("{version}").next()?;
268 let after = pattern.split("{version}").nth(1)?;
269
270 for media_type in accept.split(',').map(|s| s.trim()) {
272 if let Some(start) = media_type.find(before) {
273 let version_start = start + before.len();
274 let remaining = &media_type[version_start..];
275
276 let version_end = if after.is_empty() {
277 remaining.len()
278 } else {
279 remaining.find(after).unwrap_or(remaining.len())
280 };
281
282 let version_str = &remaining[..version_end];
283 if let Ok(version) = version_str.parse() {
284 return Some(version);
285 }
286 }
287 }
288 None
289 }
290
291 pub fn strip_version_from_path(&self, path: &str) -> String {
293 for strategy in &self.strategies {
294 if let VersionStrategy::Path { pattern } = strategy {
295 if let Some(stripped) = Self::strip_path_version(path, pattern) {
296 return stripped;
297 }
298 }
299 }
300 path.to_string()
301 }
302
303 fn strip_path_version(path: &str, pattern: &str) -> Option<String> {
305 let before = pattern.split("{version}").next()?;
306 let after = pattern.split("{version}").nth(1)?;
307
308 if let Some(start) = path.find(before) {
309 let version_start = start + before.len();
310 let remaining = &path[version_start..];
311
312 let version_end = if after.is_empty() {
313 remaining.len()
314 } else {
315 remaining.find(after)?
316 };
317
318 let version_str = &remaining[..version_end];
320 if version_str.parse::<ApiVersion>().is_ok() {
321 let prefix = &path[..start];
322 let suffix = &remaining[version_end + after.len()..];
325 if path.starts_with('/') && prefix.is_empty() && !suffix.starts_with('/') {
327 return Some(format!("/{}", suffix));
328 }
329 return Some(format!("{}{}", prefix, suffix));
330 }
331 }
332 None
333 }
334}
335
336impl Default for VersionExtractor {
337 fn default() -> Self {
338 Self::new()
339 }
340}
341
342#[derive(Debug, Clone)]
344pub struct ExtractedVersion {
345 pub version: ApiVersion,
347 pub source: VersionSource,
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub enum VersionSource {
354 Path,
356 Header,
358 Query,
360 Accept,
362 Default,
364 Custom,
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn test_extract_from_path() {
374 let extractor = VersionExtractor::new();
375
376 assert_eq!(
377 extractor.extract_from_path("/v1/users"),
378 Some(ApiVersion::major(1))
379 );
380 assert_eq!(
381 extractor.extract_from_path("/v2/products/123"),
382 Some(ApiVersion::major(2))
383 );
384 assert_eq!(
385 extractor.extract_from_path("/v1.2/items"),
386 Some(ApiVersion::new(1, 2, 0))
387 );
388 }
389
390 #[test]
391 fn test_extract_from_header() {
392 let extractor = VersionExtractor::with_strategy(VersionStrategy::header());
393 let mut headers = HashMap::new();
394 headers.insert("x-api-version".to_string(), "2.0".to_string());
395
396 assert_eq!(
397 extractor.extract_from_headers(&headers),
398 Some(ApiVersion::new(2, 0, 0))
399 );
400 }
401
402 #[test]
403 fn test_extract_from_query() {
404 let extractor = VersionExtractor::with_strategy(VersionStrategy::query());
405
406 assert_eq!(
407 extractor.extract_from_query("version=1&other=value"),
408 Some(ApiVersion::major(1))
409 );
410 assert_eq!(
411 extractor.extract_from_query("foo=bar&version=2.1"),
412 Some(ApiVersion::new(2, 1, 0))
413 );
414 }
415
416 #[test]
417 fn test_extract_from_accept() {
418 let extractor = VersionExtractor::with_strategy(VersionStrategy::accept());
419 let mut headers = HashMap::new();
420 headers.insert(
421 "accept".to_string(),
422 "application/vnd.api.v2+json".to_string(),
423 );
424
425 assert_eq!(
426 extractor.extract_from_headers(&headers),
427 Some(ApiVersion::major(2))
428 );
429 }
430
431 #[test]
432 fn test_strip_version_from_path() {
433 let extractor = VersionExtractor::new();
434
435 assert_eq!(extractor.strip_version_from_path("/v1/users"), "/users");
436 assert_eq!(
437 extractor.strip_version_from_path("/v2.0/products/123"),
438 "/products/123"
439 );
440 }
441
442 #[test]
443 fn test_multiple_strategies() {
444 let extractor = VersionExtractor::with_strategies(vec![
445 VersionStrategy::path(),
446 VersionStrategy::header(),
447 VersionStrategy::query(),
448 ])
449 .default_version(ApiVersion::v1());
450
451 assert_eq!(
453 extractor.extract_from_path("/v2/test"),
454 Some(ApiVersion::major(2))
455 );
456
457 assert_eq!(
459 extractor.extract_from_query("version=3"),
460 Some(ApiVersion::major(3))
461 );
462 }
463
464 #[test]
465 fn test_custom_path_pattern() {
466 let extractor =
467 VersionExtractor::with_strategy(VersionStrategy::path_with_pattern("/api/{version}/"));
468
469 assert_eq!(
470 extractor.extract_from_path("/api/1/users"),
471 Some(ApiVersion::major(1))
472 );
473 assert_eq!(
474 extractor.extract_from_path("/api/2.0/products"),
475 Some(ApiVersion::new(2, 0, 0))
476 );
477 }
478}