1use std::fmt;
3use std::str;
4use std::sync::Arc;
5
6use crate::error::FederationError;
7use crate::error::SingleFederationError;
8
9#[derive(Clone, PartialEq, Eq, Hash, Debug)]
11pub struct Identity {
12 pub domain: String,
15
16 pub name: Arc<str>,
19}
20
21impl fmt::Display for Identity {
22 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
31 write!(f, "{}/{}", self.domain, self.name)
32 }
33}
34
35#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
37pub struct Version {
38 pub major: u32,
40
41 pub minor: u32,
43}
44
45impl fmt::Display for Version {
46 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
51 write!(f, "{}.{}", self.major, self.minor)
52 }
53}
54
55impl str::FromStr for Version {
56 type Err = FederationError;
57
58 fn from_str(s: &str) -> Result<Self, Self::Err> {
59 let (major, minor) =
60 s.split_once('.')
61 .ok_or(SingleFederationError::InvalidLinkIdentifier {
62 message: format!(
63 r#"Version number `{s}` in @link(url:) argument is missing a period (.)"#
64 ),
65 })?;
66
67 let major =
68 major
69 .parse::<u32>()
70 .map_err(|_| SingleFederationError::InvalidLinkIdentifier {
71 message: format!(
72 r#"Major version `{major}` in @link(url:) argument is not a valid number"#
73 ),
74 })?;
75 let minor =
76 minor
77 .parse::<u32>()
78 .map_err(|_| SingleFederationError::InvalidLinkIdentifier {
79 message: format!(
80 r#"Minor version `{minor}` in @link(url:) argument is not a valid number"#
81 ),
82 })?;
83
84 Ok(Version { major, minor })
85 }
86}
87
88impl Version {
89 pub fn satisfies(&self, required: &Version) -> bool {
98 if self.major == 0 {
99 self == required
100 } else {
101 self.major == required.major && self.minor >= required.minor
102 }
103 }
104
105 pub fn satisfies_range(&self, min: &Version, max: &Version) -> bool {
118 assert_eq!(min.major, max.major);
119 assert!(min.minor < max.minor);
120
121 self.major == min.major && self.minor >= min.minor && self.minor <= max.minor
122 }
123}
124
125#[derive(Clone, PartialEq, Eq, Debug, Hash)]
127pub struct Url {
128 pub identity: Identity,
130
131 pub version: Version,
133}
134
135impl fmt::Display for Url {
136 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
148 write!(f, "{}/v{}", self.identity, self.version)
149 }
150}
151
152impl str::FromStr for Url {
153 type Err = FederationError;
154
155 fn from_str(s: &str) -> Result<Self, Self::Err> {
156 match url::Url::parse(s) {
157 Ok(url) => {
158 let mut segments =
159 url.path_segments()
160 .ok_or(SingleFederationError::InvalidLinkIdentifier {
161 message: format!(
162 r#"@link(url:) argument `"{s}"` missing leading slash after scheme"#
163 ),
164 })?;
165 let version =
166 segments
167 .next_back()
168 .ok_or(SingleFederationError::InvalidLinkIdentifier {
169 message: format!(
170 r#"@link(url:) argument `"{s}"` missing specification version"#
171 ),
172 })?;
173 if !version.starts_with('v') {
174 return Err(SingleFederationError::InvalidLinkIdentifier {
175 message: format!(r#"Last path segment of @link(url:) argument `"{s}"` should start with 'v' to indicate version"#),
176 }.into());
177 }
178 let version = version.strip_prefix('v').unwrap().parse::<Version>()?;
179 let name = segments
180 .next_back()
181 .ok_or(SingleFederationError::InvalidLinkIdentifier {
182 message: format!(
183 r#"@link(url:) argument `"{s}"` missing specification name"#
184 ),
185 })
186 .map(Arc::from)?;
187 if !url.scheme().starts_with("http") {
188 return Err(SingleFederationError::InvalidLinkIdentifier {
189 message: format!(
190 r#"@link(url:) argument `"{s}"` must use the HTTP(S) scheme"#
191 ),
192 }
193 .into());
194 }
195 let origin = url.origin();
196 if !origin.is_tuple() {
197 return Err(SingleFederationError::InvalidLinkIdentifier {
198 message: format!(r#"@link(url:) argument `"{s}"` must have a host"#),
199 }
200 .into());
201 }
202 let origin = origin.ascii_serialization();
203 let path_remainder = segments.collect::<Vec<&str>>();
204 let domain = if path_remainder.is_empty() {
205 origin
206 } else {
207 format!("{origin}/{}", path_remainder.join("/"))
208 };
209 Ok(Url {
210 identity: Identity { domain, name },
211 version,
212 })
213 }
214 Err(e) => Err(SingleFederationError::InvalidLinkIdentifier {
215 message: format!(r#"@link(url:) argument `"{s}"` is not a valid URL: {e}"#),
216 }
217 .into()),
218 }
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use apollo_compiler::name;
225
226 use super::*;
227
228 #[test]
229 fn versions_compares_correctly() {
230 assert!(Version { major: 0, minor: 0 } < Version { major: 0, minor: 1 });
231 assert!(Version { major: 1, minor: 1 } < Version { major: 1, minor: 4 });
232 assert!(Version { major: 1, minor: 4 } < Version { major: 2, minor: 0 });
233
234 assert_eq!(
235 Version { major: 0, minor: 0 },
236 Version { major: 0, minor: 0 }
237 );
238 assert_eq!(
239 Version { major: 2, minor: 3 },
240 Version { major: 2, minor: 3 }
241 );
242 }
243
244 #[test]
245 fn valid_versions_can_be_parsed() {
246 assert_eq!(
247 "0.0".parse::<Version>().unwrap(),
248 Version { major: 0, minor: 0 }
249 );
250 assert_eq!(
251 "0.5".parse::<Version>().unwrap(),
252 Version { major: 0, minor: 5 }
253 );
254 assert_eq!(
255 "2.49".parse::<Version>().unwrap(),
256 Version {
257 major: 2,
258 minor: 49
259 }
260 );
261 }
262
263 #[test]
264 fn invalid_versions_strings_return_menaingful_errors() {
265 "foo"
266 .parse::<Version>()
267 .expect_err(r#"Version number `foo` in @link(url:) argument is missing a period (.)"#);
268 "foo.bar"
269 .parse::<Version>()
270 .expect_err(r#"Major version `foo` in @link(url:) argument is not a valid number"#);
271 "0.bar"
272 .parse::<Version>()
273 .expect_err(r#"Minor version `bar` in @link(url:) argument is not a valid number"#);
274 "0.12-foo"
275 .parse::<Version>()
276 .expect_err(r#"Minor version `12-foo` in @link(url:) argument is not a valid number"#);
277 "0.12.2"
278 .parse::<Version>()
279 .expect_err(r#"Minor version `12.2` in @link(url:) argument is not a valid number"#);
280 }
281
282 #[test]
283 fn valid_urls_can_be_parsed() {
284 assert_eq!(
285 "https://specs.apollo.dev/federation/v2.3"
286 .parse::<Url>()
287 .unwrap(),
288 Url {
289 identity: Identity {
290 domain: "https://specs.apollo.dev".to_string(),
291 name: name!("federation").into(),
292 },
293 version: Version { major: 2, minor: 3 }
294 }
295 );
296
297 assert_eq!(
298 "http://something.com/more/path/my_spec_name/v0.1?k=2"
299 .parse::<Url>()
300 .unwrap(),
301 Url {
302 identity: Identity {
303 domain: "http://something.com/more/path".to_string(),
304 name: name!("my_spec_name").into(),
305 },
306 version: Version { major: 0, minor: 1 }
307 }
308 );
309
310 assert_eq!(
312 "http://localhost:8080/foo/v1.0".parse::<Url>().unwrap(),
313 Url {
314 identity: Identity {
315 domain: "http://localhost:8080".to_string(),
316 name: name!("foo").into(),
317 },
318 version: Version { major: 1, minor: 0 }
319 }
320 );
321
322 assert_eq!(
324 "http://localhost:8080/extra/foo/v1.0"
325 .parse::<Url>()
326 .unwrap(),
327 Url {
328 identity: Identity {
329 domain: "http://localhost:8080/extra".to_string(),
330 name: name!("foo").into(),
331 },
332 version: Version { major: 1, minor: 0 }
333 }
334 );
335
336 assert_eq!(
338 "https://specs.apollo.dev:443/federation/v2.3"
339 .parse::<Url>()
340 .unwrap(),
341 Url {
342 identity: Identity {
343 domain: "https://specs.apollo.dev".to_string(),
344 name: name!("federation").into(),
345 },
346 version: Version { major: 2, minor: 3 }
347 }
348 );
349 }
350}