Skip to main content

a3s_boot/
versioning.rs

1use crate::BootRequest;
2use serde_json::Value;
3use std::fmt;
4
5/// Application-wide API version extraction strategy.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum ApiVersioningStrategy {
8    /// Extract versions from path segments such as `/v1/cats`.
9    Uri { prefix: String },
10    /// Extract versions from a request header such as `x-api-version: 1`.
11    Header { header: String },
12    /// Extract versions from media type parameters such as `Accept: application/json;v=1`.
13    MediaType { key: String },
14}
15
16/// Adapter-neutral API versioning configuration.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ApiVersioning {
19    strategy: ApiVersioningStrategy,
20    default_version: Option<String>,
21}
22
23impl ApiVersioning {
24    pub fn uri() -> Self {
25        Self::uri_with_prefix("v")
26    }
27
28    pub fn uri_with_prefix(prefix: impl Into<String>) -> Self {
29        Self::new(ApiVersioningStrategy::Uri {
30            prefix: prefix.into(),
31        })
32    }
33
34    pub fn header(header: impl Into<String>) -> Self {
35        Self::new(ApiVersioningStrategy::Header {
36            header: header.into(),
37        })
38    }
39
40    pub fn media_type() -> Self {
41        Self::media_type_with_key("v")
42    }
43
44    pub fn media_type_with_key(key: impl Into<String>) -> Self {
45        Self::new(ApiVersioningStrategy::MediaType { key: key.into() })
46    }
47
48    pub fn new(strategy: ApiVersioningStrategy) -> Self {
49        Self {
50            strategy,
51            default_version: None,
52        }
53    }
54
55    pub fn with_default_version(mut self, version: impl Into<String>) -> Self {
56        self.default_version = Some(normalize_version(version));
57        self
58    }
59
60    pub fn strategy(&self) -> &ApiVersioningStrategy {
61        &self.strategy
62    }
63
64    pub fn default_version(&self) -> Option<&str> {
65        self.default_version.as_deref()
66    }
67
68    pub(crate) fn request_candidates(&self, request: &BootRequest) -> Vec<ApiVersionCandidate> {
69        match &self.strategy {
70            ApiVersioningStrategy::Uri { prefix } => uri_candidates(request.path(), prefix),
71            ApiVersioningStrategy::Header { header } => vec![ApiVersionCandidate {
72                path: request.path().to_string(),
73                version: request
74                    .header(header)
75                    .map(normalize_version)
76                    .filter(|version| !version.is_empty()),
77            }],
78            ApiVersioningStrategy::MediaType { key } => vec![ApiVersionCandidate {
79                path: request.path().to_string(),
80                version: media_type_version(request.header_values("accept"), key),
81            }],
82        }
83    }
84
85    pub(crate) fn path_candidates(&self, path: &str) -> Vec<ApiVersionCandidate> {
86        match &self.strategy {
87            ApiVersioningStrategy::Uri { prefix } => uri_candidates(path, prefix),
88            ApiVersioningStrategy::Header { .. } | ApiVersioningStrategy::MediaType { .. } => {
89                vec![ApiVersionCandidate {
90                    path: path.to_string(),
91                    version: None,
92                }]
93            }
94        }
95    }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub(crate) struct ApiVersionCandidate {
100    pub path: String,
101    pub version: Option<String>,
102}
103
104/// Version metadata attached to a route or controller.
105#[derive(Debug, Clone, PartialEq, Eq, Default)]
106pub enum RouteVersioning {
107    #[default]
108    Unspecified,
109    Versions(Vec<String>),
110    Neutral,
111}
112
113impl RouteVersioning {
114    pub fn versions<I, V>(versions: I) -> Self
115    where
116        I: IntoIterator<Item = V>,
117        V: Into<String>,
118    {
119        let mut values = Vec::new();
120        for version in versions {
121            let version = normalize_version(version);
122            if !version.is_empty() && !values.contains(&version) {
123                values.push(version);
124            }
125        }
126        Self::Versions(values)
127    }
128
129    pub fn version(version: impl Into<String>) -> Self {
130        Self::versions([version])
131    }
132
133    pub fn neutral() -> Self {
134        Self::Neutral
135    }
136
137    pub fn is_unspecified(&self) -> bool {
138        matches!(self, Self::Unspecified)
139    }
140
141    pub fn matches(&self, requested: Option<&str>, default_version: Option<&str>) -> bool {
142        match self {
143            Self::Neutral => true,
144            Self::Unspecified => match (requested, default_version) {
145                (Some(requested), Some(default)) => requested == default,
146                (Some(_), None) => false,
147                (None, _) => true,
148            },
149            Self::Versions(versions) => {
150                let requested = requested.or(default_version);
151                requested
152                    .map(|requested| versions.iter().any(|version| version == requested))
153                    .unwrap_or(false)
154            }
155        }
156    }
157
158    pub fn overlaps(&self, other: &Self, default_version: Option<&str>) -> bool {
159        match (self, other) {
160            (Self::Neutral, _) | (_, Self::Neutral) => true,
161            (Self::Unspecified, Self::Unspecified) => true,
162            (Self::Unspecified, Self::Versions(versions))
163            | (Self::Versions(versions), Self::Unspecified) => default_version
164                .map(|default| versions.iter().any(|version| version == default))
165                .unwrap_or(false),
166            (Self::Versions(left), Self::Versions(right)) => {
167                left.iter().any(|version| right.contains(version))
168            }
169        }
170    }
171}
172
173impl fmt::Display for RouteVersioning {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        match self {
176            Self::Unspecified => f.write_str("unspecified"),
177            Self::Neutral => f.write_str("neutral"),
178            Self::Versions(versions) => f.write_str(&versions.join(",")),
179        }
180    }
181}
182
183fn uri_candidates(path: &str, prefix: &str) -> Vec<ApiVersionCandidate> {
184    let segments = path
185        .trim_start_matches('/')
186        .split('/')
187        .filter(|segment| !segment.is_empty())
188        .collect::<Vec<_>>();
189
190    for (index, segment) in segments.iter().enumerate() {
191        let Some(version) = segment.strip_prefix(prefix) else {
192            continue;
193        };
194        if version.is_empty() {
195            continue;
196        }
197
198        let mut remaining = segments.clone();
199        remaining.remove(index);
200        let path = if remaining.is_empty() {
201            "/".to_string()
202        } else {
203            format!("/{}", remaining.join("/"))
204        };
205
206        return vec![ApiVersionCandidate {
207            path,
208            version: Some(normalize_version(version)),
209        }];
210    }
211
212    vec![ApiVersionCandidate {
213        path: path.to_string(),
214        version: None,
215    }]
216}
217
218fn media_type_version(values: Vec<&str>, key: &str) -> Option<String> {
219    for value in values {
220        for media_range in value.split(',') {
221            for parameter in media_range.split(';').skip(1) {
222                let Some((name, value)) = parameter.trim().split_once('=') else {
223                    continue;
224                };
225                if name.trim().eq_ignore_ascii_case(key) {
226                    let version = normalize_version(value.trim().trim_matches('"'));
227                    if !version.is_empty() {
228                        return Some(version);
229                    }
230                }
231            }
232        }
233    }
234    None
235}
236
237fn normalize_version(version: impl Into<String>) -> String {
238    let version = version.into();
239    match serde_json::from_str::<Value>(&version) {
240        Ok(Value::Number(number)) => number.to_string(),
241        _ => version.trim().to_string(),
242    }
243}