1use 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#[derive(Clone, PartialEq, Eq, Hash, Debug)]
18pub struct Identity {
19 pub domain: String,
22
23 pub name: String,
26}
27
28impl fmt::Display for Identity {
29 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#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
59pub struct Version {
60 pub major: u32,
62
63 pub minor: u32,
65}
66
67impl fmt::Display for Version {
68 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 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 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#[derive(Clone, PartialEq, Eq, Debug)]
135pub struct Url {
136 pub identity: Identity,
138
139 pub version: Version,
141}
142
143impl fmt::Display for Url {
144 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}