use std::fmt;
use std::str;
use url;
use thiserror::Error;
pub const APOLLO_SPEC_DOMAIN: &str = "https://specs.apollo.dev";
#[derive(Error, Debug, PartialEq)]
pub enum SpecError {
#[error("Parse error: {0}")]
ParseError(String),
}
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub struct Identity {
pub domain: String,
pub name: String,
}
impl fmt::Display for Identity {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}/{}", self.domain, self.name)
}
}
impl Identity {
pub fn link_identity() -> Identity {
Identity {
domain: APOLLO_SPEC_DOMAIN.to_string(),
name: "link".to_string(),
}
}
pub fn federation_identity() -> Identity {
Identity {
domain: APOLLO_SPEC_DOMAIN.to_string(),
name: "federation".to_string(),
}
}
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct Version {
pub major: u32,
pub minor: u32,
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}.{}", self.major, self.minor)
}
}
impl str::FromStr for Version {
type Err = SpecError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (major, minor) = s.split_once('.').ok_or(SpecError::ParseError(
"version number is missing a dot (.)".to_string(),
))?;
let major = major.parse::<u32>().map_err(|_| {
SpecError::ParseError(format!("invalid major version number '{}'", major))
})?;
let minor = minor.parse::<u32>().map_err(|_| {
SpecError::ParseError(format!("invalid minor version number '{}'", minor))
})?;
Ok(Version { major, minor })
}
}
impl Version {
pub fn satisfies(&self, required: &Version) -> bool {
if self.major == 0 {
self == required
} else {
self.major == required.major && self.minor >= required.minor
}
}
pub fn satisfies_range(&self, min: &Version, max: &Version) -> bool {
assert_eq!(min.major, max.major);
assert!(min.minor < max.minor);
self.major == min.major && self.minor >= min.minor && self.minor <= max.minor
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Url {
pub identity: Identity,
pub version: Version,
}
impl fmt::Display for Url {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}/v{}", self.identity, self.version)
}
}
impl str::FromStr for Url {
type Err = SpecError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match url::Url::parse(s) {
Ok(url) => {
let mut segments = url.path_segments().ok_or(SpecError::ParseError(
"invalid `@link` specification url".to_string(),
))?;
let version = segments.next_back().ok_or(SpecError::ParseError(
"invalid `@link` specification url: missing specification version".to_string(),
))?;
if !version.starts_with('v') {
return Err(SpecError::ParseError("invalid `@link` specification url: the last element of the path should be the version starting with a 'v'".to_string()));
}
let version = version.strip_prefix('v').unwrap().parse::<Version>()?;
let name = segments.next_back().ok_or(SpecError::ParseError(
"invalid `@link` specification url: missing specification name".to_string(),
))?;
let scheme = url.scheme();
if !scheme.starts_with("http") {
return Err(SpecError::ParseError("invalid `@link` specification url: only http(s) urls are supported currently".to_string()));
}
let url_domain = url.domain().ok_or(SpecError::ParseError(
"invalid `@link` specification url".to_string(),
))?;
let path_remainder = segments.collect::<Vec<&str>>();
let domain = if path_remainder.is_empty() {
format!("{}://{}", scheme, url_domain)
} else {
format!("{}://{}/{}", scheme, url_domain, path_remainder.join("/"))
};
Ok(Url {
identity: Identity {
domain,
name: name.to_string(),
},
version,
})
}
Err(e) => Err(SpecError::ParseError(format!(
"invalid specification url: {}",
e
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions_compares_correctly() {
assert!(Version { major: 0, minor: 0 } < Version { major: 0, minor: 1 });
assert!(Version { major: 1, minor: 1 } < Version { major: 1, minor: 4 });
assert!(Version { major: 1, minor: 4 } < Version { major: 2, minor: 0 });
assert_eq!(
Version { major: 0, minor: 0 },
Version { major: 0, minor: 0 }
);
assert_eq!(
Version { major: 2, minor: 3 },
Version { major: 2, minor: 3 }
);
}
#[test]
fn valid_versions_can_be_parsed() {
assert_eq!(
"0.0".parse::<Version>().unwrap(),
Version { major: 0, minor: 0 }
);
assert_eq!(
"0.5".parse::<Version>().unwrap(),
Version { major: 0, minor: 5 }
);
assert_eq!(
"2.49".parse::<Version>().unwrap(),
Version {
major: 2,
minor: 49
}
);
}
#[test]
fn invalid_versions_strings_return_menaingful_errors() {
assert_eq!(
"foo".parse::<Version>(),
Err(SpecError::ParseError(
"version number is missing a dot (.)".to_string()
))
);
assert_eq!(
"foo.bar".parse::<Version>(),
Err(SpecError::ParseError(
"invalid major version number 'foo'".to_string()
))
);
assert_eq!(
"0.bar".parse::<Version>(),
Err(SpecError::ParseError(
"invalid minor version number 'bar'".to_string()
))
);
assert_eq!(
"0.12-foo".parse::<Version>(),
Err(SpecError::ParseError(
"invalid minor version number '12-foo'".to_string()
))
);
assert_eq!(
"0.12.2".parse::<Version>(),
Err(SpecError::ParseError(
"invalid minor version number '12.2'".to_string()
))
);
}
#[test]
fn valid_urls_can_be_parsed() {
assert_eq!(
"https://specs.apollo.dev/federation/v2.3"
.parse::<Url>()
.unwrap(),
Url {
identity: Identity {
domain: "https://specs.apollo.dev".to_string(),
name: "federation".to_string()
},
version: Version { major: 2, minor: 3 }
}
);
assert_eq!(
"http://something.com/more/path/my_spec_name/v0.1?k=2"
.parse::<Url>()
.unwrap(),
Url {
identity: Identity {
domain: "http://something.com/more/path".to_string(),
name: "my_spec_name".to_string()
},
version: Version { major: 0, minor: 1 }
}
);
}
}