use serde::Deserialize;
use crate::version::Version;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Manifest {
pub id: String,
pub version: String,
pub schema_version: Version,
pub interface_version: Version,
pub network_hosts: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManifestField {
Id,
Version,
SchemaVersion,
InterfaceVersion,
NetworkHosts,
}
impl core::fmt::Display for ManifestField {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let name = match self {
Self::Id => "id",
Self::Version => "version",
Self::SchemaVersion => "schema_version",
Self::InterfaceVersion => "interface_version",
Self::NetworkHosts => "network_hosts",
};
f.write_str(name)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
#[error("manifest is not valid TOML: {0}")]
Malformed(#[from] toml::de::Error),
#[error("invalid manifest field {field}: {reason}")]
InvalidField {
field: ManifestField,
reason: String,
},
}
#[derive(Debug, Deserialize)]
struct RawManifest {
id: Option<String>,
version: Option<String>,
schema_version: Option<String>,
interface_version: Option<String>,
network_hosts: Option<Vec<String>>,
}
impl Manifest {
pub fn parse(source: &str) -> Result<Self, ManifestError> {
let raw: RawManifest = toml::from_str(source)?;
let id = required(raw.id, ManifestField::Id)?;
let version = required(raw.version, ManifestField::Version)?;
let schema_version = parse_version(raw.schema_version, ManifestField::SchemaVersion)?;
let interface_version =
parse_version(raw.interface_version, ManifestField::InterfaceVersion)?;
let network_hosts = required(raw.network_hosts, ManifestField::NetworkHosts)?;
if network_hosts.iter().any(|host| host.trim().is_empty()) {
return Err(ManifestError::InvalidField {
field: ManifestField::NetworkHosts,
reason: "network_hosts entries must not be empty".to_owned(),
});
}
Ok(Self {
id,
version,
schema_version,
interface_version,
network_hosts,
})
}
}
fn required<T>(value: Option<T>, field: ManifestField) -> Result<T, ManifestError> {
value.ok_or_else(|| ManifestError::InvalidField {
field,
reason: "field is required".to_owned(),
})
}
fn parse_version(value: Option<String>, field: ManifestField) -> Result<Version, ManifestError> {
let raw = required(value, field)?;
raw.parse().map_err(|_| ManifestError::InvalidField {
field,
reason: format!("{raw:?} is not a valid \"major.minor\" version"),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_toml() -> &'static str {
r#"
id = "bundesliga"
version = "0.1.0"
schema_version = "1.0"
interface_version = "1.0"
network_hosts = ["api.openligadb.de"]
"#
}
#[test]
fn parses_a_well_formed_manifest() {
let manifest = Manifest::parse(valid_toml()).unwrap();
assert_eq!(manifest.id, "bundesliga");
assert_eq!(manifest.schema_version, Version::new(1, 0));
assert_eq!(manifest.network_hosts, vec!["api.openligadb.de".to_owned()]);
}
#[test]
fn rejects_missing_required_field() {
let err = Manifest::parse("id = \"x\"").unwrap_err();
assert!(matches!(
err,
ManifestError::InvalidField {
field: ManifestField::Version,
..
}
));
}
#[test]
fn rejects_malformed_version_string() {
let toml = r#"
id = "bundesliga"
version = "0.1.0"
schema_version = "not-a-version"
interface_version = "1.0"
network_hosts = ["api.openligadb.de"]
"#;
let err = Manifest::parse(toml).unwrap_err();
assert!(matches!(
err,
ManifestError::InvalidField {
field: ManifestField::SchemaVersion,
..
}
));
}
#[test]
fn rejects_empty_network_host_entry() {
let toml = r#"
id = "bundesliga"
version = "0.1.0"
schema_version = "1.0"
interface_version = "1.0"
network_hosts = [""]
"#;
let err = Manifest::parse(toml).unwrap_err();
assert!(matches!(
err,
ManifestError::InvalidField {
field: ManifestField::NetworkHosts,
..
}
));
}
#[test]
fn rejects_malformed_toml() {
let err = Manifest::parse("not = [valid").unwrap_err();
assert!(matches!(err, ManifestError::Malformed(_)));
}
#[test]
fn does_not_contact_declared_network_hosts() {
let toml = r#"
id = "x"
version = "0.1.0"
schema_version = "1.0"
interface_version = "1.0"
network_hosts = ["definitely-not-a-real-host.invalid"]
"#;
assert!(Manifest::parse(toml).is_ok());
}
#[test]
fn interface_version_2_0_is_accepted_by_the_current_interface_version() {
let toml = r#"
id = "bundesliga"
version = "0.1.0"
schema_version = "1.0"
interface_version = "2.0"
network_hosts = ["api.openligadb.de"]
"#;
let manifest = Manifest::parse(toml).unwrap();
assert_eq!(manifest.interface_version, Version::new(2, 0));
assert!(crate::INTERFACE_VERSION.accepts(manifest.interface_version));
}
#[test]
fn interface_version_1_0_is_rejected_after_the_host_fetch_major_bump() {
let manifest = Manifest::parse(valid_toml()).unwrap();
assert_eq!(manifest.interface_version, Version::new(1, 0));
assert!(!crate::INTERFACE_VERSION.accepts(manifest.interface_version));
}
}