Skip to main content

apollo_federation/link/
spec.rs

1//! Representation of spec identities, versions, and URLs.
2use std::fmt;
3use std::str;
4use std::sync::Arc;
5
6use crate::error::FederationError;
7use crate::error::SingleFederationError;
8
9/// Represents the identity of a `@link` specification, which uniquely identify a specification.
10#[derive(Clone, PartialEq, Eq, Hash, Debug)]
11pub struct Identity {
12    /// The "domain" of which the specification this identifies is part of. For instance,
13    /// `"https://specs.apollo.dev"`.
14    pub domain: String,
15
16    /// The name of the specification this identifies. For instance, "federation". This isn't
17    /// guaranteed to a valid GraphQL name.
18    pub name: Arc<str>,
19}
20
21impl fmt::Display for Identity {
22    /// Display a specification identity.
23    ///
24    ///     # use apollo_federation::link::spec::Identity;
25    ///     use apollo_compiler::name;
26    ///     assert_eq!(
27    ///         Identity { domain: "https://specs.apollo.dev".to_string(), name: name!("federation").into() }.to_string(),
28    ///         "https://specs.apollo.dev/federation"
29    ///     )
30    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
31        write!(f, "{}/{}", self.domain, self.name)
32    }
33}
34
35/// The version of a `@link` specification, in the form of a major and minor version numbers.
36#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
37pub struct Version {
38    /// The major number part of the version.
39    pub major: u32,
40
41    /// The minor number part of the version.
42    pub minor: u32,
43}
44
45impl fmt::Display for Version {
46    /// Display a specification version number.
47    ///
48    ///     # use apollo_federation::link::spec::Version;
49    ///     assert_eq!(Version { major: 2, minor: 3 }.to_string(), "2.3")
50    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    /// Whether this version satisfies the provided `required` version.
90    ///
91    ///     # use apollo_federation::link::spec::Version;
92    ///     assert!(&Version { major: 1, minor: 0 }.satisfies(&Version{ major: 1, minor: 0 }));
93    ///     assert!(&Version { major: 1, minor: 2 }.satisfies(&Version{ major: 1, minor: 0 }));
94    ///
95    ///     assert!(!(&Version { major: 2, minor: 0 }.satisfies(&Version{ major: 1, minor: 9 })));
96    ///     assert!(!(&Version { major: 0, minor: 9 }.satisfies(&Version{ major: 0, minor: 8 })));
97    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    /// Verifies whether this version satisfies the provided version range.
106    ///
107    /// # Panics
108    /// The `min` and `max` must be the same major version, and `max` minor version must be higher than `min`'s.
109    /// Else, you get a panic.
110    ///
111    /// # Examples
112    ///
113    ///     # use apollo_federation::link::spec::Version;
114    ///     assert!(&Version { major: 1, minor: 1 }.satisfies_range(&Version{ major: 1, minor: 0 }, &Version{ major: 1, minor: 10 }));
115    ///
116    ///     assert!(!&Version { major: 2, minor: 0 }.satisfies_range(&Version{ major: 1, minor: 0 }, &Version{ major: 1, minor: 10 }));
117    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/// A `@link` specification url, which identifies a specific version of a specification.
126#[derive(Clone, PartialEq, Eq, Debug, Hash)]
127pub struct Url {
128    /// The identity of the `@link` specification pointed by this url.
129    pub identity: Identity,
130
131    /// The version of the `@link` specification pointed by this url.
132    pub version: Version,
133}
134
135impl fmt::Display for Url {
136    /// Display a specification url.
137    ///
138    ///     # use apollo_federation::link::spec::*;
139    ///     use apollo_compiler::name;
140    ///     assert_eq!(
141    ///         Url {
142    ///           identity: Identity { domain: "https://specs.apollo.dev".to_string(), name: name!("federation").into() },
143    ///           version: Version { major: 2, minor: 3 }
144    ///         }.to_string(),
145    ///         "https://specs.apollo.dev/federation/v2.3"
146    ///     )
147    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        // Non-default port is preserved in the identity domain.
311        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        // Non-default port is preserved alongside a path remainder.
323        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        // Default https port is omitted, so existing identities are unaffected.
337        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}