Skip to main content

apollo_federation/link/
spec.rs

1//! Representation of Apollo `@link` specifications.
2use std::fmt;
3use std::str;
4
5use apollo_compiler::Name;
6use apollo_compiler::name;
7use thiserror::Error;
8
9use crate::error::FederationError;
10use crate::error::SingleFederationError;
11
12pub const APOLLO_SPEC_DOMAIN: &str = "https://specs.apollo.dev";
13
14#[derive(Error, Debug, PartialEq)]
15pub enum SpecError {
16    #[error("Parse error: {0}")]
17    ParseError(String),
18}
19
20// TODO: Replace SpecError usages with FederationError.
21impl From<SpecError> for FederationError {
22    fn from(value: SpecError) -> Self {
23        SingleFederationError::InvalidLinkIdentifier {
24            message: value.to_string(),
25        }
26        .into()
27    }
28}
29
30/// Represents the identity of a `@link` specification, which uniquely identify a specification.
31#[derive(Clone, PartialEq, Eq, Hash, Debug)]
32pub struct Identity {
33    /// The "domain" of which the specification this identifies is part of.
34    /// For instance, `"https://specs.apollo.dev"`.
35    pub domain: String,
36
37    /// The name of the specification this identifies.
38    /// For instance, "federation".
39    pub name: Name,
40}
41
42impl fmt::Display for Identity {
43    /// Display a specification identity.
44    ///
45    ///     # use apollo_federation::link::spec::Identity;
46    ///     use apollo_compiler::name;
47    ///     assert_eq!(
48    ///         Identity { domain: "https://specs.apollo.dev".to_string(), name: name!("federation") }.to_string(),
49    ///         "https://specs.apollo.dev/federation"
50    ///     )
51    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
52        write!(f, "{}/{}", self.domain, self.name)
53    }
54}
55
56impl Identity {
57    pub fn core_identity() -> Identity {
58        Identity {
59            domain: APOLLO_SPEC_DOMAIN.to_string(),
60            name: name!("core"),
61        }
62    }
63
64    pub fn link_identity() -> Identity {
65        Identity {
66            domain: APOLLO_SPEC_DOMAIN.to_string(),
67            name: name!("link"),
68        }
69    }
70
71    pub fn federation_identity() -> Identity {
72        Identity {
73            domain: APOLLO_SPEC_DOMAIN.to_string(),
74            name: name!("federation"),
75        }
76    }
77
78    pub fn join_identity() -> Identity {
79        Identity {
80            domain: APOLLO_SPEC_DOMAIN.to_string(),
81            name: name!("join"),
82        }
83    }
84
85    pub fn inaccessible_identity() -> Identity {
86        Identity {
87            domain: APOLLO_SPEC_DOMAIN.to_string(),
88            name: name!("inaccessible"),
89        }
90    }
91
92    pub fn cost_identity() -> Identity {
93        Identity {
94            domain: APOLLO_SPEC_DOMAIN.to_string(),
95            name: name!("cost"),
96        }
97    }
98
99    pub fn context_identity() -> Identity {
100        Identity {
101            domain: APOLLO_SPEC_DOMAIN.to_string(),
102            name: name!("context"),
103        }
104    }
105
106    pub fn requires_scopes_identity() -> Identity {
107        Identity {
108            domain: APOLLO_SPEC_DOMAIN.to_string(),
109            name: name!("requiresScopes"),
110        }
111    }
112
113    pub fn authenticated_identity() -> Identity {
114        Identity {
115            domain: APOLLO_SPEC_DOMAIN.to_string(),
116            name: name!("authenticated"),
117        }
118    }
119
120    pub fn policy_identity() -> Identity {
121        Identity {
122            domain: APOLLO_SPEC_DOMAIN.to_string(),
123            name: name!("policy"),
124        }
125    }
126}
127
128/// The version of a `@link` specification, in the form of a major and minor version numbers.
129#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
130pub struct Version {
131    /// The major number part of the version.
132    pub major: u32,
133
134    /// The minor number part of the version.
135    pub minor: u32,
136}
137
138impl fmt::Display for Version {
139    /// Display a specification version number.
140    ///
141    ///     # use apollo_federation::link::spec::Version;
142    ///     assert_eq!(Version { major: 2, minor: 3 }.to_string(), "2.3")
143    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
144        write!(f, "{}.{}", self.major, self.minor)
145    }
146}
147
148impl str::FromStr for Version {
149    type Err = SpecError;
150
151    fn from_str(s: &str) -> Result<Self, Self::Err> {
152        let (major, minor) = s.split_once('.').ok_or(SpecError::ParseError(
153            "version number is missing a dot (.)".to_string(),
154        ))?;
155
156        let major = major.parse::<u32>().map_err(|_| {
157            SpecError::ParseError(format!("invalid major version number '{}'", major))
158        })?;
159        let minor = minor.parse::<u32>().map_err(|_| {
160            SpecError::ParseError(format!("invalid minor version number '{}'", minor))
161        })?;
162
163        Ok(Version { major, minor })
164    }
165}
166
167impl Version {
168    /// Whether this version satisfies the provided `required` version.
169    ///
170    ///     # use apollo_federation::link::spec::Version;
171    ///     assert!(&Version { major: 1, minor: 0 }.satisfies(&Version{ major: 1, minor: 0 }));
172    ///     assert!(&Version { major: 1, minor: 2 }.satisfies(&Version{ major: 1, minor: 0 }));
173    ///
174    ///     assert!(!(&Version { major: 2, minor: 0 }.satisfies(&Version{ major: 1, minor: 9 })));
175    ///     assert!(!(&Version { major: 0, minor: 9 }.satisfies(&Version{ major: 0, minor: 8 })));
176    pub fn satisfies(&self, required: &Version) -> bool {
177        if self.major == 0 {
178            self == required
179        } else {
180            self.major == required.major && self.minor >= required.minor
181        }
182    }
183
184    /// Verifies whether this version satisfies the provided version range.
185    ///
186    /// # Panics
187    /// The `min` and `max` must be the same major version, and `max` minor version must be higher than `min`'s.
188    /// Else, you get a panic.
189    ///
190    /// # Examples
191    ///
192    ///     # use apollo_federation::link::spec::Version;
193    ///     assert!(&Version { major: 1, minor: 1 }.satisfies_range(&Version{ major: 1, minor: 0 }, &Version{ major: 1, minor: 10 }));
194    ///
195    ///     assert!(!&Version { major: 2, minor: 0 }.satisfies_range(&Version{ major: 1, minor: 0 }, &Version{ major: 1, minor: 10 }));
196    pub fn satisfies_range(&self, min: &Version, max: &Version) -> bool {
197        assert_eq!(min.major, max.major);
198        assert!(min.minor < max.minor);
199
200        self.major == min.major && self.minor >= min.minor && self.minor <= max.minor
201    }
202}
203
204/// A `@link` specification url, which identifies a specific version of a specification.
205#[derive(Clone, PartialEq, Eq, Debug)]
206pub struct Url {
207    /// The identity of the `@link` specification pointed by this url.
208    pub identity: Identity,
209
210    /// The version of the `@link` specification pointed by this url.
211    pub version: Version,
212}
213
214impl fmt::Display for Url {
215    /// Display a specification url.
216    ///
217    ///     # use apollo_federation::link::spec::*;
218    ///     use apollo_compiler::name;
219    ///     assert_eq!(
220    ///         Url {
221    ///           identity: Identity { domain: "https://specs.apollo.dev".to_string(), name: name!("federation") },
222    ///           version: Version { major: 2, minor: 3 }
223    ///         }.to_string(),
224    ///         "https://specs.apollo.dev/federation/v2.3"
225    ///     )
226    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
227        write!(f, "{}/v{}", self.identity, self.version)
228    }
229}
230
231impl str::FromStr for Url {
232    type Err = SpecError;
233
234    fn from_str(s: &str) -> Result<Self, Self::Err> {
235        match url::Url::parse(s) {
236            Ok(url) => {
237                let mut segments = url.path_segments().ok_or(SpecError::ParseError(
238                    "invalid `@link` specification url".to_string(),
239                ))?;
240                let version = segments.next_back().ok_or(SpecError::ParseError(
241                    "invalid `@link` specification url: missing specification version".to_string(),
242                ))?;
243                if !version.starts_with('v') {
244                    return Err(SpecError::ParseError("invalid `@link` specification url: the last element of the path should be the version starting with a 'v'".to_string()));
245                }
246                let version = version.strip_prefix('v').unwrap().parse::<Version>()?;
247                let name = segments
248                    .next_back()
249                    .ok_or(SpecError::ParseError(
250                        "invalid `@link` specification url: missing specification name".to_string(),
251                    ))
252                    // Note this is SUPER wrong, but the JS federation implementation didn't check
253                    // if the name was valid, and customers are actively using URLs with for example dashes.
254                    // So we pretend that it's fine. You can't reference an imported element by the
255                    // namespaced name because it's not valid GraphQL to do so--but you can
256                    // explicitly import elements from a spec with an invalid name.
257                    .map(Name::new_unchecked)?;
258                let scheme = url.scheme();
259                if !scheme.starts_with("http") {
260                    return Err(SpecError::ParseError("invalid `@link` specification url: only http(s) urls are supported currently".to_string()));
261                }
262                let url_domain = url.domain().ok_or(SpecError::ParseError(
263                    "invalid `@link` specification url".to_string(),
264                ))?;
265                let path_remainder = segments.collect::<Vec<&str>>();
266                let domain = if path_remainder.is_empty() {
267                    format!("{}://{}", scheme, url_domain)
268                } else {
269                    format!("{}://{}/{}", scheme, url_domain, path_remainder.join("/"))
270                };
271                Ok(Url {
272                    identity: Identity { domain, name },
273                    version,
274                })
275            }
276            Err(e) => Err(SpecError::ParseError(format!(
277                "invalid specification url: {}",
278                e
279            ))),
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use apollo_compiler::name;
287
288    use super::*;
289
290    #[test]
291    fn versions_compares_correctly() {
292        assert!(Version { major: 0, minor: 0 } < Version { major: 0, minor: 1 });
293        assert!(Version { major: 1, minor: 1 } < Version { major: 1, minor: 4 });
294        assert!(Version { major: 1, minor: 4 } < Version { major: 2, minor: 0 });
295
296        assert_eq!(
297            Version { major: 0, minor: 0 },
298            Version { major: 0, minor: 0 }
299        );
300        assert_eq!(
301            Version { major: 2, minor: 3 },
302            Version { major: 2, minor: 3 }
303        );
304    }
305
306    #[test]
307    fn valid_versions_can_be_parsed() {
308        assert_eq!(
309            "0.0".parse::<Version>().unwrap(),
310            Version { major: 0, minor: 0 }
311        );
312        assert_eq!(
313            "0.5".parse::<Version>().unwrap(),
314            Version { major: 0, minor: 5 }
315        );
316        assert_eq!(
317            "2.49".parse::<Version>().unwrap(),
318            Version {
319                major: 2,
320                minor: 49
321            }
322        );
323    }
324
325    #[test]
326    fn invalid_versions_strings_return_menaingful_errors() {
327        assert_eq!(
328            "foo".parse::<Version>(),
329            Err(SpecError::ParseError(
330                "version number is missing a dot (.)".to_string()
331            ))
332        );
333        assert_eq!(
334            "foo.bar".parse::<Version>(),
335            Err(SpecError::ParseError(
336                "invalid major version number 'foo'".to_string()
337            ))
338        );
339        assert_eq!(
340            "0.bar".parse::<Version>(),
341            Err(SpecError::ParseError(
342                "invalid minor version number 'bar'".to_string()
343            ))
344        );
345        assert_eq!(
346            "0.12-foo".parse::<Version>(),
347            Err(SpecError::ParseError(
348                "invalid minor version number '12-foo'".to_string()
349            ))
350        );
351        assert_eq!(
352            "0.12.2".parse::<Version>(),
353            Err(SpecError::ParseError(
354                "invalid minor version number '12.2'".to_string()
355            ))
356        );
357    }
358
359    #[test]
360    fn valid_urls_can_be_parsed() {
361        assert_eq!(
362            "https://specs.apollo.dev/federation/v2.3"
363                .parse::<Url>()
364                .unwrap(),
365            Url {
366                identity: Identity {
367                    domain: "https://specs.apollo.dev".to_string(),
368                    name: name!("federation")
369                },
370                version: Version { major: 2, minor: 3 }
371            }
372        );
373
374        assert_eq!(
375            "http://something.com/more/path/my_spec_name/v0.1?k=2"
376                .parse::<Url>()
377                .unwrap(),
378            Url {
379                identity: Identity {
380                    domain: "http://something.com/more/path".to_string(),
381                    name: name!("my_spec_name")
382                },
383                version: Version { major: 0, minor: 1 }
384            }
385        );
386    }
387}