apollo_at_link/
spec.rs

1//! Representation of Apollo `@link` specifications.
2use std::fmt;
3use std::str;
4use url;
5
6use thiserror::Error;
7
8pub const APOLLO_SPEC_DOMAIN: &str = "https://specs.apollo.dev";
9
10#[derive(Error, Debug, PartialEq)]
11pub enum SpecError {
12    #[error("Parse error: {0}")]
13    ParseError(String),
14}
15
16/// Represents the identity of a `@link` specification, which uniquely identify a specification.
17#[derive(Clone, PartialEq, Eq, Hash, Debug)]
18pub struct Identity {
19    /// The "domain" of which the specification this identifies is part of.
20    /// For instance, "https://specs.apollo.dev".
21    pub domain: String,
22
23    /// The name of the specification this identifies.
24    /// For instance, "federation".
25    pub name: String,
26}
27
28impl fmt::Display for Identity {
29    /// Display a specification identity.
30    ///
31    ///     # use apollo_at_link::spec::Identity;
32    ///     assert_eq!(
33    ///         Identity { domain: "https://specs.apollo.dev".to_string(), name: "federation".to_string() }.to_string(),
34    ///         "https://specs.apollo.dev/federation"
35    ///     )
36    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
37        write!(f, "{}/{}", self.domain, self.name)
38    }
39}
40
41impl Identity {
42    pub fn link_identity() -> Identity {
43        Identity {
44            domain: APOLLO_SPEC_DOMAIN.to_string(),
45            name: "link".to_string(),
46        }
47    }
48
49    pub fn federation_identity() -> Identity {
50        Identity {
51            domain: APOLLO_SPEC_DOMAIN.to_string(),
52            name: "federation".to_string(),
53        }
54    }
55}
56
57/// The version of a `@link` specification, in the form of a major and minor version numbers.
58#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
59pub struct Version {
60    /// The major number part of the version.
61    pub major: u32,
62
63    /// The minor number part of the version.
64    pub minor: u32,
65}
66
67impl fmt::Display for Version {
68    /// Display a specification version number.
69    ///
70    ///     # use apollo_at_link::spec::Version;
71    ///     assert_eq!(Version { major: 2, minor: 3 }.to_string(), "2.3")
72    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
73        write!(f, "{}.{}", self.major, self.minor)
74    }
75}
76
77impl str::FromStr for Version {
78    type Err = SpecError;
79
80    fn from_str(s: &str) -> Result<Self, Self::Err> {
81        let (major, minor) = s.split_once('.').ok_or(SpecError::ParseError(
82            "version number is missing a dot (.)".to_string(),
83        ))?;
84
85        let major = major.parse::<u32>().map_err(|_| {
86            SpecError::ParseError(format!("invalid major version number '{}'", major))
87        })?;
88        let minor = minor.parse::<u32>().map_err(|_| {
89            SpecError::ParseError(format!("invalid minor version number '{}'", minor))
90        })?;
91
92        Ok(Version { major, minor })
93    }
94}
95
96impl Version {
97    /// Whether this version satisfies the provided `required` version.
98    ///
99    ///     # use apollo_at_link::spec::Version;
100    ///     assert!(&Version { major: 1, minor: 0 }.satisfies(&Version{ major: 1, minor: 0 }));
101    ///     assert!(&Version { major: 1, minor: 2 }.satisfies(&Version{ major: 1, minor: 0 }));
102    ///
103    ///     assert!(!(&Version { major: 2, minor: 0 }.satisfies(&Version{ major: 1, minor: 9 })));
104    ///     assert!(!(&Version { major: 0, minor: 9 }.satisfies(&Version{ major: 0, minor: 8 })));
105    pub fn satisfies(&self, required: &Version) -> bool {
106        if self.major == 0 {
107            self == required
108        } else {
109            self.major == required.major && self.minor >= required.minor
110        }
111    }
112
113    /// Verifies whether this version satisfies the provided version range.
114    ///
115    /// # Panics
116    /// The `min` and `max` must be the same major version, and `max` minor version must be higher than `min`'s.
117    /// Else, you get a panic.
118    ///
119    /// # Examples
120    ///
121    ///     # use apollo_at_link::spec::Version;
122    ///     assert!(&Version { major: 1, minor: 1 }.satisfies_range(&Version{ major: 1, minor: 0 }, &Version{ major: 1, minor: 10 }));
123    ///
124    ///     assert!(!&Version { major: 2, minor: 0 }.satisfies_range(&Version{ major: 1, minor: 0 }, &Version{ major: 1, minor: 10 }));
125    pub fn satisfies_range(&self, min: &Version, max: &Version) -> bool {
126        assert_eq!(min.major, max.major);
127        assert!(min.minor < max.minor);
128
129        self.major == min.major && self.minor >= min.minor && self.minor <= max.minor
130    }
131}
132
133/// A `@link` specification url, which identifies a specific version of a specification.
134#[derive(Clone, PartialEq, Eq, Debug)]
135pub struct Url {
136    /// The identity of the `@link` specification pointed by this url.
137    pub identity: Identity,
138
139    /// The version of the `@link` specification pointed by this url.
140    pub version: Version,
141}
142
143impl fmt::Display for Url {
144    /// Display a specification url.
145    ///
146    ///     # use apollo_at_link::spec::*;
147    ///     assert_eq!(
148    ///         Url {
149    ///           identity: Identity { domain: "https://specs.apollo.dev".to_string(), name: "federation".to_string() },
150    ///           version: Version { major: 2, minor: 3 }
151    ///         }.to_string(),
152    ///         "https://specs.apollo.dev/federation/v2.3"
153    ///     )
154    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
155        write!(f, "{}/v{}", self.identity, self.version)
156    }
157}
158
159impl str::FromStr for Url {
160    type Err = SpecError;
161
162    fn from_str(s: &str) -> Result<Self, Self::Err> {
163        match url::Url::parse(s) {
164            Ok(url) => {
165                let mut segments = url.path_segments().ok_or(SpecError::ParseError(
166                    "invalid `@link` specification url".to_string(),
167                ))?;
168                let version = segments.next_back().ok_or(SpecError::ParseError(
169                    "invalid `@link` specification url: missing specification version".to_string(),
170                ))?;
171                if !version.starts_with('v') {
172                    return Err(SpecError::ParseError("invalid `@link` specification url: the last element of the path should be the version starting with a 'v'".to_string()));
173                }
174                let version = version.strip_prefix('v').unwrap().parse::<Version>()?;
175                let name = segments.next_back().ok_or(SpecError::ParseError(
176                    "invalid `@link` specification url: missing specification name".to_string(),
177                ))?;
178                let scheme = url.scheme();
179                if !scheme.starts_with("http") {
180                    return Err(SpecError::ParseError("invalid `@link` specification url: only http(s) urls are supported currently".to_string()));
181                }
182                let url_domain = url.domain().ok_or(SpecError::ParseError(
183                    "invalid `@link` specification url".to_string(),
184                ))?;
185                let path_remainder = segments.collect::<Vec<&str>>();
186                let domain = if path_remainder.is_empty() {
187                    format!("{}://{}", scheme, url_domain)
188                } else {
189                    format!("{}://{}/{}", scheme, url_domain, path_remainder.join("/"))
190                };
191                Ok(Url {
192                    identity: Identity {
193                        domain,
194                        name: name.to_string(),
195                    },
196                    version,
197                })
198            }
199            Err(e) => Err(SpecError::ParseError(format!(
200                "invalid specification url: {}",
201                e
202            ))),
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn versions_compares_correctly() {
213        assert!(Version { major: 0, minor: 0 } < Version { major: 0, minor: 1 });
214        assert!(Version { major: 1, minor: 1 } < Version { major: 1, minor: 4 });
215        assert!(Version { major: 1, minor: 4 } < Version { major: 2, minor: 0 });
216
217        assert_eq!(
218            Version { major: 0, minor: 0 },
219            Version { major: 0, minor: 0 }
220        );
221        assert_eq!(
222            Version { major: 2, minor: 3 },
223            Version { major: 2, minor: 3 }
224        );
225    }
226
227    #[test]
228    fn valid_versions_can_be_parsed() {
229        assert_eq!(
230            "0.0".parse::<Version>().unwrap(),
231            Version { major: 0, minor: 0 }
232        );
233        assert_eq!(
234            "0.5".parse::<Version>().unwrap(),
235            Version { major: 0, minor: 5 }
236        );
237        assert_eq!(
238            "2.49".parse::<Version>().unwrap(),
239            Version {
240                major: 2,
241                minor: 49
242            }
243        );
244    }
245
246    #[test]
247    fn invalid_versions_strings_return_menaingful_errors() {
248        assert_eq!(
249            "foo".parse::<Version>(),
250            Err(SpecError::ParseError(
251                "version number is missing a dot (.)".to_string()
252            ))
253        );
254        assert_eq!(
255            "foo.bar".parse::<Version>(),
256            Err(SpecError::ParseError(
257                "invalid major version number 'foo'".to_string()
258            ))
259        );
260        assert_eq!(
261            "0.bar".parse::<Version>(),
262            Err(SpecError::ParseError(
263                "invalid minor version number 'bar'".to_string()
264            ))
265        );
266        assert_eq!(
267            "0.12-foo".parse::<Version>(),
268            Err(SpecError::ParseError(
269                "invalid minor version number '12-foo'".to_string()
270            ))
271        );
272        assert_eq!(
273            "0.12.2".parse::<Version>(),
274            Err(SpecError::ParseError(
275                "invalid minor version number '12.2'".to_string()
276            ))
277        );
278    }
279
280    #[test]
281    fn valid_urls_can_be_parsed() {
282        assert_eq!(
283            "https://specs.apollo.dev/federation/v2.3"
284                .parse::<Url>()
285                .unwrap(),
286            Url {
287                identity: Identity {
288                    domain: "https://specs.apollo.dev".to_string(),
289                    name: "federation".to_string()
290                },
291                version: Version { major: 2, minor: 3 }
292            }
293        );
294
295        assert_eq!(
296            "http://something.com/more/path/my_spec_name/v0.1?k=2"
297                .parse::<Url>()
298                .unwrap(),
299            Url {
300                identity: Identity {
301                    domain: "http://something.com/more/path".to_string(),
302                    name: "my_spec_name".to_string()
303                },
304                version: Version { major: 0, minor: 1 }
305            }
306        );
307    }
308}