use crate::backend::DeltaCapabilities;
use crate::error::{DeltaApiError, DeltaApiResult};
pub(crate) const SUPPORTED_VERSIONS: &[&str] = &["1.0"];
pub(crate) const CORE_ENDPOINTS: &[&str] = &[
"POST /v1/catalogs/{catalog}/schemas/{schema}/staging-tables",
"POST /v1/catalogs/{catalog}/schemas/{schema}/tables",
"GET /v1/catalogs/{catalog}/schemas/{schema}/tables/{table}",
"POST /v1/catalogs/{catalog}/schemas/{schema}/tables/{table}",
"DELETE /v1/catalogs/{catalog}/schemas/{schema}/tables/{table}",
"HEAD /v1/catalogs/{catalog}/schemas/{schema}/tables/{table}",
"GET /v1/catalogs/{catalog}/schemas/{schema}/tables/{table}/credentials",
"POST /v1/catalogs/{catalog}/schemas/{schema}/tables/{table}/metrics",
"GET /v1/staging-tables/{table_id}/credentials",
"GET /v1/temporary-path-credentials",
];
const RENAME_ENDPOINT: &str = "POST /v1/catalogs/{catalog}/schemas/{schema}/tables/{table}/rename";
pub(crate) fn endpoints_for(caps: DeltaCapabilities) -> Vec<String> {
let mut endpoints: Vec<String> = CORE_ENDPOINTS.iter().map(|s| s.to_string()).collect();
if caps.rename {
endpoints.push(RENAME_ENDPOINT.to_string());
}
endpoints
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct Version {
major: u32,
minor: u32,
}
impl Version {
fn parse(s: &str) -> Option<Version> {
let (major, minor) = s.trim().split_once('.')?;
Some(Version {
major: major.trim().parse().ok()?,
minor: minor.trim().parse().ok()?,
})
}
fn covers(self, other: Version) -> bool {
self.major == other.major && self.minor >= other.minor
}
}
pub(crate) fn negotiate_version(client_versions: &str) -> DeltaApiResult<String> {
let client: Vec<Version> = client_versions
.split(',')
.filter_map(Version::parse)
.collect();
let best = SUPPORTED_VERSIONS
.iter()
.filter_map(|supported| {
let server = Version::parse(supported).expect("SUPPORTED_VERSIONS entries are valid");
client.iter().any(|c| c.covers(server)).then_some(server)
})
.max();
match best {
Some(v) => Ok(format!("{}.{}", v.major, v.minor)),
None => Err(DeltaApiError::invalid_argument(format!(
"no mutually supported protocol version; server supports: {}",
SUPPORTED_VERSIONS.join(", ")
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::DeltaBackendError;
#[test]
fn negotiate_exact_match() {
assert_eq!(negotiate_version("1.0").unwrap(), "1.0");
}
#[test]
fn negotiate_higher_minor_covers_lower() {
assert_eq!(negotiate_version("1.1").unwrap(), "1.0");
}
#[test]
fn negotiate_picks_from_multi_major_list() {
assert_eq!(negotiate_version("2.3,1.0").unwrap(), "1.0");
assert_eq!(negotiate_version("1.1,2.3").unwrap(), "1.0");
}
#[test]
fn negotiate_tolerates_whitespace_and_garbage() {
assert_eq!(negotiate_version(" 1.0 ").unwrap(), "1.0");
assert_eq!(negotiate_version("nonsense, 1.0").unwrap(), "1.0");
}
#[test]
fn version_orders_by_major_then_minor() {
assert!(Version::parse("2.0").unwrap() > Version::parse("1.9").unwrap());
assert!(Version::parse("1.10").unwrap() > Version::parse("1.9").unwrap());
}
#[test]
fn negotiate_no_overlap_is_invalid_argument() {
let err = negotiate_version("2.0,3.1").unwrap_err();
assert!(
matches!(err.0, DeltaBackendError::InvalidArgument(_)),
"{err:?}"
);
assert!(err.to_string().contains("1.0"), "{err}");
}
#[test]
fn negotiate_empty_and_garbage_only_is_400() {
assert!(negotiate_version("").is_err());
assert!(negotiate_version("garbage").is_err());
}
#[test]
fn core_endpoints_omit_config_and_list_tables() {
let core = endpoints_for(DeltaCapabilities::default());
assert_eq!(core.len(), 10);
assert!(!core.iter().any(|e| e == "GET /v1/config"));
assert!(
!core
.iter()
.any(|e| e == "GET /v1/catalogs/{catalog}/schemas/{schema}/tables"),
"listTables must not be advertised"
);
assert!(!core.iter().any(|e| e == RENAME_ENDPOINT));
}
#[test]
fn rename_capability_adds_the_rename_endpoint() {
let with_rename = endpoints_for(DeltaCapabilities { rename: true });
assert_eq!(with_rename.len(), 11);
assert!(with_rename.iter().any(|e| e == RENAME_ENDPOINT));
}
}