1use 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
20impl 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#[derive(Clone, PartialEq, Eq, Hash, Debug)]
32pub struct Identity {
33 pub domain: String,
36
37 pub name: Name,
40}
41
42impl fmt::Display for Identity {
43 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#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
158pub struct Version {
159 pub major: u32,
161
162 pub minor: u32,
164}
165
166impl fmt::Display for Version {
167 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 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 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#[derive(Clone, PartialEq, Eq, Debug, Hash)]
234pub struct Url {
235 pub identity: Identity,
237
238 pub version: Version,
240}
241
242impl fmt::Display for Url {
243 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 .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}