use serde::{Deserialize, Serialize};
const fn header_default() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TableFormat {
Csv {
header: bool,
},
Json,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Schema {
#[default]
Auto,
Named(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Index {
Ip,
Prefix,
Column(String),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SourceArchive {
#[default]
Raw,
Gzip,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TableSource {
pub url: String,
#[serde(default)]
pub checksum_url: Option<String>,
#[serde(default)]
pub archive: SourceArchive,
pub file: String,
pub format: TableFormat,
#[serde(default)]
pub schema: Schema,
pub index: Index,
#[serde(skip)]
pub http_client: Option<reqwest::Client>,
}
impl TableSource {
#[must_use]
pub fn new(
url: impl Into<String>,
file: impl Into<String>,
format: TableFormat,
index: Index,
) -> Self {
Self {
url: url.into(),
checksum_url: None,
archive: SourceArchive::Raw,
file: file.into(),
format,
schema: Schema::Auto,
index,
http_client: None,
}
}
#[must_use]
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = Some(client);
self
}
}
impl PartialEq for TableSource {
fn eq(&self, other: &Self) -> bool {
self.url == other.url
&& self.checksum_url == other.checksum_url
&& self.archive == other.archive
&& self.file == other.file
&& self.format == other.format
&& self.schema == other.schema
&& self.index == other.index
}
}
impl Eq for TableSource {}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum BareFormat {
Csv,
Json,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct CsvOptions {
#[serde(default = "header_default")]
header: bool,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
enum FullFormat {
Csv(CsvOptions),
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum FormatWire {
Bare(BareFormat),
Full(FullFormat),
}
impl Serialize for TableFormat {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match *self {
Self::Json => FormatWire::Bare(BareFormat::Json),
Self::Csv { header: true } => FormatWire::Bare(BareFormat::Csv),
Self::Csv { header } => FormatWire::Full(FullFormat::Csv(CsvOptions { header })),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for TableFormat {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(match FormatWire::deserialize(deserializer)? {
FormatWire::Bare(BareFormat::Json) => Self::Json,
FormatWire::Bare(BareFormat::Csv) => Self::Csv {
header: header_default(),
},
FormatWire::Full(FullFormat::Csv(CsvOptions { header })) => Self::Csv { header },
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn networks() -> TableSource {
TableSource::new(
"https://example.net/networks.csv",
"networks.csv",
TableFormat::Csv { header: true },
Index::Column("asn".to_string()),
)
}
#[test]
fn a_source_needs_four_things_and_defaults_the_rest() {
let source = networks();
assert_eq!(source.archive, SourceArchive::Raw);
assert_eq!(source.schema, Schema::Auto);
assert!(source.checksum_url.is_none());
assert!(source.http_client.is_none());
}
#[test]
fn the_common_case_writes_four_keys() {
let json = r#"{
"url": "https://example.net/networks.csv",
"file": "networks.csv",
"format": "csv",
"index": {"column": "asn"}
}"#;
let source: TableSource = serde_json::from_str(json).unwrap();
assert_eq!(source, networks());
assert_eq!(source.format, TableFormat::Csv { header: true });
}
#[test]
fn the_headerless_case_states_its_names() {
let json = r#"{
"url": "https://example.net/networks.csv",
"file": "networks.csv",
"format": {"csv": {"header": false}},
"schema": {"named": ["asn", "name", "country"]},
"index": {"column": "asn"}
}"#;
let source: TableSource = serde_json::from_str(json).unwrap();
assert_eq!(source.format, TableFormat::Csv { header: false });
assert_eq!(
source.schema,
Schema::Named(vec![
"asn".to_string(),
"name".to_string(),
"country".to_string(),
])
);
}
#[test]
fn a_format_round_trips_through_the_form_an_operator_writes() {
for (format, wire) in [
(TableFormat::Csv { header: true }, "\"csv\""),
(TableFormat::Json, "\"json\""),
(
TableFormat::Csv { header: false },
r#"{"csv":{"header":false}}"#,
),
] {
assert_eq!(serde_json::to_string(&format).unwrap(), wire);
assert_eq!(serde_json::from_str::<TableFormat>(wire).unwrap(), format);
}
}
#[test]
fn an_index_and_a_schema_round_trip() {
for (index, wire) in [
(Index::Ip, "\"ip\""),
(Index::Column("asn".to_string()), r#"{"column":"asn"}"#),
] {
assert_eq!(serde_json::to_string(&index).unwrap(), wire);
assert_eq!(serde_json::from_str::<Index>(wire).unwrap(), index);
}
assert_eq!(serde_json::to_string(&Schema::Auto).unwrap(), "\"auto\"");
assert_eq!(
serde_json::from_str::<Schema>(r#"{"named":["a"]}"#).unwrap(),
Schema::Named(vec!["a".to_string()])
);
}
#[test]
fn a_misspelt_key_is_rejected() {
let json = r#"{
"url": "https://example.net/networks.csv",
"file": "networks.csv",
"format": "csv",
"indx": {"column": "asn"}
}"#;
assert!(serde_json::from_str::<TableSource>(json).is_err());
let json = r#"{
"url": "https://example.net/networks.csv",
"file": "networks.csv",
"format": {"csv": {"headr": false}},
"index": "ip"
}"#;
assert!(serde_json::from_str::<TableSource>(json).is_err());
}
#[test]
fn a_whole_source_round_trips() {
let mut source = networks();
source.checksum_url = Some("https://example.net/networks.csv.sha256".to_string());
source.archive = SourceArchive::Gzip;
source.schema = Schema::Named(vec!["asn".to_string()]);
let dumped = serde_json::to_string(&source).unwrap();
assert_eq!(
serde_json::from_str::<TableSource>(&dumped).unwrap(),
source
);
}
#[test]
fn an_injected_client_is_not_a_config_change() {
let injected = networks().with_http_client(reqwest::Client::new());
assert_eq!(networks(), injected);
assert!(injected.http_client.is_some());
}
}