use std::collections::BTreeMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
pub(crate) const PROJECTS_LIST_PATH: &str = "/data/api/v1/projects/list";
pub(crate) const PROJECTS_CREATE_PATH: &str = "/data/api/v1/projects";
pub(crate) const PROJECTS_COPY_PATH: &str = "/data/api/v1/projects/copy";
pub(crate) fn project_find_path(name: &str) -> String {
format!("/data/api/v1/projects/find/{}", encode_segment(name))
}
pub(crate) fn project_rename_path(name: &str) -> String {
format!("/data/api/v1/projects/rename/{}", encode_segment(name))
}
pub(crate) fn project_modify_path(name: &str) -> String {
format!("/data/api/v1/projects/{}", encode_segment(name))
}
pub(crate) fn project_delete_path(name: &str) -> String {
format!("/data/api/v1/projects/{}", encode_segment(name))
}
pub(crate) fn project_export_path(name: &str) -> String {
format!("/data/api/v1/projects/export/{}", encode_segment(name))
}
pub(crate) fn project_import_path(name: &str) -> String {
format!("/data/api/v1/projects/import/{}", encode_segment(name))
}
pub const PROJECT_EXPORT_TIMEOUT: Duration = Duration::from_secs(120);
pub const PROJECT_IMPORT_TIMEOUT: Duration = Duration::from_secs(300);
pub(crate) fn encode_segment(segment: &str) -> String {
percent_encoding::utf8_percent_encode(segment, percent_encoding::NON_ALPHANUMERIC).to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectRecord {
pub name: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub parent: Option<String>,
#[serde(default)]
pub inheritable: Option<bool>,
#[serde(default)]
pub default_db: Option<String>,
#[serde(default)]
pub tag_provider: Option<String>,
#[serde(default)]
pub user_source: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectCreate {
pub name: String,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inheritable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_db: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_source: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectModify {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inheritable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_db: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_source: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectCopy {
#[serde(rename = "fromName")]
pub from_name: String,
#[serde(rename = "toName")]
pub to_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectRenameBody {
pub name: String,
}
#[derive(Debug, Clone)]
pub struct ExportMeta {
pub filename: Option<String>,
pub bytes: u64,
pub content_type: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ImportOutcome {
pub response: serde_json::Value,
}
pub(crate) fn import_denied(body: &serde_json::Value) -> Option<String> {
if body.get("success") != Some(&serde_json::Value::Bool(false)) {
return None;
}
Some(
body.get("problem")
.and_then(|problem| problem.as_str())
.map(str::to_string)
.unwrap_or_else(|| {
"gateway reported success:false without a problem message".to_string()
}),
)
}
#[cfg(test)]
mod tests {
use super::{ProjectCreate, ProjectModify, encode_segment, import_denied};
#[test]
fn encode_segment_handles_spaces_and_symbols() {
assert_eq!(encode_segment("My Project"), "My%20Project");
assert_eq!(encode_segment("plain"), "plain");
assert_eq!(
encode_segment("a/b"),
"a%2Fb",
"even / encodes — resource paths split first"
);
}
#[test]
fn bare_create_serializes_exactly_name_and_enabled() {
let body = ProjectCreate {
name: "x".into(),
enabled: true,
title: None,
description: None,
parent: None,
inheritable: None,
default_db: None,
tag_provider: None,
user_source: None,
};
assert_eq!(
serde_json::to_value(&body).expect("serializes"),
serde_json::json!({"name": "x", "enabled": true}),
"absent optionals are OMITTED, never null/empty strings"
);
}
#[test]
fn modify_serializes_only_provided_fields() {
let body = ProjectModify {
enabled: None,
title: Some("T".into()),
description: None,
parent: None,
inheritable: None,
default_db: None,
tag_provider: None,
user_source: None,
};
assert_eq!(
serde_json::to_value(&body).expect("serializes"),
serde_json::json!({"title": "T"})
);
}
#[test]
fn import_denied_pins_the_denial_family() {
assert_eq!(
import_denied(&serde_json::json!({
"success": false,
"problem": "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}"
}))
.as_deref(),
Some("resource already exists: ResourceId{resourcePath=com.example, collectionName=views}")
);
assert_eq!(import_denied(&serde_json::json!({"success": true})), None);
assert_eq!(
import_denied(&serde_json::json!({"status": "success"})),
None
);
assert_eq!(import_denied(&serde_json::json!({})), None);
assert_eq!(
import_denied(&serde_json::json!({"success": "false"})),
None,
"string \"false\" is the opaque family, not an explicit denial"
);
assert_eq!(
import_denied(&serde_json::json!({"success": false})).as_deref(),
Some("gateway reported success:false without a problem message")
);
}
}