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 tag_identity() -> Identity {
107        Identity {
108            domain: APOLLO_SPEC_DOMAIN.to_string(),
109            name: name!("tag"),
110        }
111    }
112
113    pub fn requires_scopes_identity() -> Identity {
114        Identity {
115            domain: APOLLO_SPEC_DOMAIN.to_string(),
116            name: name!("requiresScopes"),
117        }
118    }
119
120    pub fn authenticated_identity() -> Identity {
121        Identity {
122            domain: APOLLO_SPEC_DOMAIN.to_string(),
123            name: name!("authenticated"),
124        }
125    }
126
127    pub fn policy_identity() -> Identity {
128        Identity {
129            domain: APOLLO_SPEC_DOMAIN.to_string(),
130            name: name!("policy"),
131        }
132    }
133
134    pub fn source_identity() -> Identity {
135        Identity {
136            domain: APOLLO_SPEC_DOMAIN.to_string(),
137            name: name!("source"),
138        }
139    }
140
141    pub fn connect_identity() -> Identity {
142        Identity {
143            domain: APOLLO_SPEC_DOMAIN.to_string(),
144            name: name!("connect"),
145        }
146    }
147
148    pub fn cache_tag_identity() -> Identity {
149        Identity {
150            domain: APOLLO_SPEC_DOMAIN.to_string(),
151            name: name!("cacheTag"),
152        }
153    }
154}
155
156/// The version of a `@link` specification, in the form of a major and minor version numbers.
157#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
158pub struct Version {
159    /// The major number part of the version.
160    pub major: u32,
161
162    /// The minor number part of the version.
163    pub minor: u32,
164}
165
166impl fmt::Display for Version {
167    /// Display a specification version number.
168    ///
169    ///     # use apollo_federation::link::spec::Version;
170    ///     assert_eq!(Version { major: 2, minor: 3 }.to_string(), "2.3")
171    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
172        write!(f, "{}.{}", self.major, self.minor)
173    }
174}
175
176impl str::FromStr for Version {
177    type Err = SpecError;
178
179    fn from_str(s: &str) -> Result<Self, Self::Err> {
180        let (major, minor) = s.split_once('.').ok_or(SpecError::ParseError(
181            "version number is missing a dot (.)".to_string(),
182        ))?;
183
184        let major = major.parse::<u32>().map_err(|_| {
185            SpecError::ParseError(format!("invalid major version number '{major}'"))
186        })?;
187        let minor = minor.parse::<u32>().map_err(|_| {
188            SpecError::ParseError(format!("invalid minor version number '{minor}'"))
189        })?;
190
191        Ok(Version { major, minor })
192    }
193}
194
195impl Version {
196    /// Whether this version satisfies the provided `required` version.
197    ///
198    ///     # use apollo_federation::link::spec::Version;
199    ///     assert!(&Version { major: 1, minor: 0 }.satisfies(&Version{ major: 1, minor: 0 }));
200    ///     assert!(&Version { major: 1, minor: 2 }.satisfies(&Version{ major: 1, minor: 0 }));
201    ///
202    ///     assert!(!(&Version { major: 2, minor: 0 }.satisfies(&Version{ major: 1, minor: 9 })));
203    ///     assert!(!(&Version { major: 0, minor: 9 }.satisfies(&Version{ major: 0, minor: 8 })));
204    pub fn satisfies(&self, required: &Version) -> bool {
205        if self.major == 0 {
206            self == required
207        } else {
208            self.major == required.major && self.minor >= required.minor
209        }
210    }
211
212    /// Verifies whether this version satisfies the provided version range.
213    ///
214    /// # Panics
215    /// The `min` and `max` must be the same major version, and `max` minor version must be higher than `min`'s.
216    /// Else, you get a panic.
217    ///
218    /// # Examples
219    ///
220    ///     # use apollo_federation::link::spec::Version;
221    ///     assert!(&Version { major: 1, minor: 1 }.satisfies_range(&Version{ major: 1, minor: 0 }, &Version{ major: 1, minor: 10 }));
222    ///
223    ///     assert!(!&Version { major: 2, minor: 0 }.satisfies_range(&Version{ major: 1, minor: 0 }, &Version{ major: 1, minor: 10 }));
224    pub fn satisfies_range(&self, min: &Version, max: &Version) -> bool {
225        assert_eq!(min.major, max.major);
226        assert!(min.minor < max.minor);
227
228        self.major == min.major && self.minor >= min.minor && self.minor <= max.minor
229    }
230}
231
232/// A `@link` specification url, which identifies a specific version of a specification.
233#[derive(Clone, PartialEq, Eq, Debug, Hash)]
234pub struct Url {
235    /// The identity of the `@link` specification pointed by this url.
236    pub identity: Identity,
237
238    /// The version of the `@link` specification pointed by this url.
239    pub version: Version,
240}
241
242impl fmt::Display for Url {
243    /// Display a specification url.
244    ///
245    ///     # use apollo_federation::link::spec::*;
246    ///     use apollo_compiler::name;
247    ///     assert_eq!(
248    ///         Url {
249    ///           identity: Identity { domain: "https://specs.apollo.dev".to_string(), name: name!("federation") },
250    ///           version: Version { major: 2, minor: 3 }
251    ///         }.to_string(),
252    ///         "https://specs.apollo.dev/federation/v2.3"
253    ///     )
254    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
255        write!(f, "{}/v{}", self.identity, self.version)
256    }
257}
258
259impl str::FromStr for Url {
260    type Err = SpecError;
261
262    fn from_str(s: &str) -> Result<Self, Self::Err> {
263        match url::Url::parse(s) {
264            Ok(url) => {
265                let mut segments = url.path_segments().ok_or(SpecError::ParseError(
266                    "invalid `@link` specification url".to_string(),
267                ))?;
268                let version = segments.next_back().ok_or(SpecError::ParseError(
269                    "invalid `@link` specification url: missing specification version".to_string(),
270                ))?;
271                if !version.starts_with('v') {
272                    return Err(SpecError::ParseError("invalid `@link` specification url: the last element of the path should be the version starting with a 'v'".to_string()));
273                }
274                let version = version.strip_prefix('v').unwrap().parse::<Version>()?;
275                let name = segments
276                    .next_back()
277                    .ok_or(SpecError::ParseError(
278                        "invalid `@link` specification url: missing specification name".to_string(),
279                    ))
280                    // Note this is SUPER wrong, but the JS federation implementation didn't check
281                    // if the name was valid, and customers are actively using URLs with for example dashes.
282                    // So we pretend that it's fine. You can't reference an imported element by the
283                    // namespaced name because it's not valid GraphQL to do so--but you can
284                    // explicitly import elements from a spec with an invalid name.
285                    .map(Name::new_unchecked)?;
286                let scheme = url.scheme();
287                if !scheme.starts_with("http") {
288                    return Err(SpecError::ParseError("invalid `@link` specification url: only http(s) urls are supported currently".to_string()));
289                }
290                let url_domain = url.domain().ok_or(SpecError::ParseError(
291                    "invalid `@link` specification url".to_string(),
292                ))?;
293                let path_remainder = segments.collect::<Vec<&str>>();
294                let domain = if path_remainder.is_empty() {
295                    format!("{scheme}://{url_domain}")
296                } else {
297                    format!("{}://{}/{}", scheme, url_domain, path_remainder.join("/"))
298                };
299                Ok(Url {
300                    identity: Identity { domain, name },
301                    version,
302                })
303            }
304            Err(e) => Err(SpecError::ParseError(format!(
305                "invalid specification url: {e}"
306            ))),
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use apollo_compiler::name;
314
315    use super::*;
316
317    #[test]
318    fn versions_compares_correctly() {
319        assert!(Version { major: 0, minor: 0 } < Version { major: 0, minor: 1 });
320        assert!(Version { major: 1, minor: 1 } < Version { major: 1, minor: 4 });
321        assert!(Version { major: 1, minor: 4 } < Version { major: 2, minor: 0 });
322
323        assert_eq!(
324            Version { major: 0, minor: 0 },
325            Version { major: 0, minor: 0 }
326        );
327        assert_eq!(
328            Version { major: 2, minor: 3 },
329            Version { major: 2, minor: 3 }
330        );
331    }
332
333    #[test]
334    fn valid_versions_can_be_parsed() {
335        assert_eq!(
336            "0.0".parse::<Version>().unwrap(),
337            Version { major: 0, minor: 0 }
338        );
339        assert_eq!(
340            "0.5".parse::<Version>().unwrap(),
341            Version { major: 0, minor: 5 }
342        );
343        assert_eq!(
344            "2.49".parse::<Version>().unwrap(),
345            Version {
346                major: 2,
347                minor: 49
348            }
349        );
350    }
351
352    #[test]
353    fn invalid_versions_strings_return_menaingful_errors() {
354        assert_eq!(
355            "foo".parse::<Version>(),
356            Err(SpecError::ParseError(
357                "version number is missing a dot (.)".to_string()
358            ))
359        );
360        assert_eq!(
361            "foo.bar".parse::<Version>(),
362            Err(SpecError::ParseError(
363                "invalid major version number 'foo'".to_string()
364            ))
365        );
366        assert_eq!(
367            "0.bar".parse::<Version>(),
368            Err(SpecError::ParseError(
369                "invalid minor version number 'bar'".to_string()
370            ))
371        );
372        assert_eq!(
373            "0.12-foo".parse::<Version>(),
374            Err(SpecError::ParseError(
375                "invalid minor version number '12-foo'".to_string()
376            ))
377        );
378        assert_eq!(
379            "0.12.2".parse::<Version>(),
380            Err(SpecError::ParseError(
381                "invalid minor version number '12.2'".to_string()
382            ))
383        );
384    }
385
386    #[test]
387    fn valid_urls_can_be_parsed() {
388        assert_eq!(
389            "https://specs.apollo.dev/federation/v2.3"
390                .parse::<Url>()
391                .unwrap(),
392            Url {
393                identity: Identity {
394                    domain: "https://specs.apollo.dev".to_string(),
395                    name: name!("federation")
396                },
397                version: Version { major: 2, minor: 3 }
398            }
399        );
400
401        assert_eq!(
402            "http://something.com/more/path/my_spec_name/v0.1?k=2"
403                .parse::<Url>()
404                .unwrap(),
405            Url {
406                identity: Identity {
407                    domain: "http://something.com/more/path".to_string(),
408                    name: name!("my_spec_name")
409                },
410                version: Version { major: 0, minor: 1 }
411            }
412        );
413    }
414}