use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use thiserror::Error;
pub const PROTOCOL_VERSION: &str = "v0";
pub const API_GROUP_FILESYSTEM_V0: &str = "filesystem/v0";
pub const API_GROUP_MAINTENANCE_V0: &str = "maintenance/v0";
pub const API_GROUP_QUERY_V0: &str = "query/v0";
pub const FEATURE_NAMESPACES_CREATE: &str = "filesystem.namespaces.create";
pub const FEATURE_NAMESPACES_FORK: &str = "filesystem.namespaces.fork";
pub const FEATURE_NAMESPACES_DELETE: &str = "filesystem.namespaces.delete";
pub const FEATURE_SNAPSHOTS: &str = "filesystem.snapshots";
pub const FEATURE_ATTRIBUTES: &str = "filesystem.attributes";
pub const FEATURE_INODES_LIST_CHILDREN: &str = "filesystem.inodes.list_children";
pub const FEATURE_COMMIT_INLINE_CONTENT: &str = "filesystem.commits.inline_content";
pub const FEATURE_UPLOADS_DIRECT_PUT: &str = "filesystem.uploads.direct_put";
pub const FEATURE_UPLOADS_DIRECT_MULTIPART: &str = "filesystem.uploads.direct_multipart";
pub const FEATURE_DOWNLOADS_DIRECT_GET: &str = "filesystem.downloads.direct_get";
pub const FEATURE_QUERY_GREP: &str = "query.grep";
pub const FEATURE_MAINTENANCE_GREP_INDEX: &str = "maintenance.grep.index";
macro_rules! limit_keys {
($( $(#[$meta:meta])* $name:ident = $value:literal; )+) => {
$(
$(#[$meta])*
pub const $name: &str = $value;
)+
pub const ALL_LIMIT_KEYS: &[&str] = &[$($name),+];
};
}
limit_keys! {
LIMIT_UPLOAD_MAX_CONTENT_BYTES = "upload.max_content_bytes";
LIMIT_UPLOAD_DIRECT_PUT_MAX_CONTENT_BYTES = "upload.direct_put_max_content_bytes";
LIMIT_UPLOAD_COMPLETION_MAX_BODY_BYTES = "upload.completion_max_body_bytes";
LIMIT_DOWNLOAD_MAX_CONTENT_BYTES = "download.max_content_bytes";
LIMIT_UPLOAD_MAX_CONCURRENT = "upload.max_concurrent";
LIMIT_DOWNLOAD_MAX_CONCURRENT = "download.max_concurrent";
LIMIT_SNAPSHOT_MAX_TTL_MS = "snapshot.max_ttl_ms";
LIMIT_SNAPSHOT_MAX_LIFETIME_MS = "snapshot.max_lifetime_ms";
LIMIT_SNAPSHOT_MAX_LIVE_PER_NAMESPACE = "snapshot.max_live_per_namespace";
LIMIT_COMMIT_MAX_OPERATIONS = "commit.max_operations";
LIMIT_COMMIT_MAX_INLINE_CONTENT_BYTES = "commit.max_inline_content_bytes";
LIMIT_ACCESS_MAX_PRINCIPALS = "access.max_principals";
LIMIT_COMMIT_MAX_PRECONDITIONS = "commit.max_preconditions";
LIMIT_COMMIT_MAX_CONTENT_TOKENS = "commit.max_content_tokens";
LIMIT_COMMIT_MAX_EXTERNAL_CONTENT_REFS = "commit.max_external_content_refs";
LIMIT_COMMIT_MAX_MESSAGE_BYTES = "commit.max_message_bytes";
LIMIT_PAGINATION_DEFAULT = "pagination.default_limit";
LIMIT_PAGINATION_MAX = "pagination.max_limit";
LIMIT_GC_MIN_GRACE_WINDOW_MS = "maintenance.gc.min_grace_window_ms";
LIMIT_QUERY_GREP_DEFAULT = "query.grep.default_limit";
LIMIT_QUERY_GREP_MAX = "query.grep.max_limit";
LIMIT_QUERY_GREP_SCAN_BUDGET_FILES = "query.grep.scan_budget_files";
LIMIT_QUERY_GREP_TAIL_BUDGET_FILES = "query.grep.tail_budget_files";
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct CapabilityDocument {
pub protocol_version: String,
pub api_groups: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub features: BTreeMap<String, bool>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub limits: BTreeMap<String, u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum CapabilityDocumentError {
#[error(
"feature `{feature}` is not parented by an advertised API group \
(its first dotted segment must be one of the advertised API group names)"
)]
UnparentedFeature {
feature: String,
},
}
impl CapabilityDocument {
pub fn supports(&self, feature: &str) -> bool {
self.features.get(feature).copied().unwrap_or(false)
}
pub fn direct_put_max_content_bytes(&self) -> Option<u64> {
self.limits
.get(LIMIT_UPLOAD_DIRECT_PUT_MAX_CONTENT_BYTES)
.copied()
}
pub fn validate(&self) -> Result<(), CapabilityDocumentError> {
let advertised_api_groups: Vec<&str> = self
.api_groups
.iter()
.map(|api_group| api_group_name(api_group))
.collect();
for feature in self.features.keys() {
if !feature_is_parented(&advertised_api_groups, feature) {
return Err(CapabilityDocumentError::UnparentedFeature {
feature: feature.clone(),
});
}
}
Ok(())
}
pub fn retain_well_formed(&mut self) {
let advertised_api_groups: Vec<&str> = self
.api_groups
.iter()
.map(|api_group| api_group_name(api_group))
.collect();
self.features
.retain(|feature, _| feature_is_parented(&advertised_api_groups, feature));
}
}
fn feature_is_parented(api_group_names: &[&str], feature: &str) -> bool {
match feature.split('.').next() {
Some(api_group) if !api_group.is_empty() => api_group_names.contains(&api_group),
_ => false,
}
}
fn api_group_name(api_group: &str) -> &str {
api_group.split('/').next().unwrap_or(api_group)
}
#[cfg(test)]
mod tests {
use super::*;
fn document() -> CapabilityDocument {
CapabilityDocument {
protocol_version: PROTOCOL_VERSION.to_owned(),
api_groups: vec![
API_GROUP_FILESYSTEM_V0.to_owned(),
API_GROUP_MAINTENANCE_V0.to_owned(),
],
features: BTreeMap::from([
(FEATURE_NAMESPACES_CREATE.to_owned(), true),
(FEATURE_NAMESPACES_DELETE.to_owned(), false),
]),
limits: BTreeMap::new(),
}
}
#[test]
fn supports_answers_gating_questions() {
let document = document();
assert!(document.supports(FEATURE_NAMESPACES_CREATE));
assert!(!document.supports(FEATURE_NAMESPACES_DELETE));
assert!(!document.supports(FEATURE_NAMESPACES_FORK));
}
#[test]
fn feature_keys_must_be_parented_by_an_advertised_api_group() {
let mut document = document();
document
.features
.insert("query.index.fulltext".to_owned(), true);
assert_eq!(
document.validate(),
Err(CapabilityDocumentError::UnparentedFeature {
feature: "query.index.fulltext".to_owned(),
})
);
document.retain_well_formed();
assert!(document.validate().is_ok());
assert!(!document.features.contains_key("query.index.fulltext"));
assert!(document.features.contains_key(FEATURE_NAMESPACES_CREATE));
}
#[test]
fn capability_document_round_trips_and_tolerates_unknown_fields() {
let document = document();
let encoded = serde_json::to_string(&document).expect("encode");
assert!(encoded.contains("\"api_groups\""));
let decoded: CapabilityDocument = serde_json::from_str(&encoded).expect("decode");
assert_eq!(decoded, document);
for old_field_name in [concat!("pl", "anes"), "profiles"] {
let old_field = encoded.replace("\"api_groups\"", &format!("\"{old_field_name}\""));
assert!(serde_json::from_str::<CapabilityDocument>(&old_field).is_err());
}
let future = encoded.replacen('{', "{\"field_from_the_future\":true,", 1);
let decoded: CapabilityDocument =
serde_json::from_str(&future).expect("unknown fields are ignored");
assert_eq!(decoded, document);
}
}